feat(llm): support incremental Responses WebSocket transport
This commit is contained in:
1 parent
73adcf25a7
commit
2a3f43fb60
22 files changed
+1176
-12
No files matched your search
@@ -9,6 +9,8 @@ OPENAI_TIMEOUT_SECONDS=300
|
||||
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
|
||||
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
|
||||
OPENAI_USE_SYSTEM_PROXY=false
|
||||
# http 保持兼容;websocket 复用连接并按已验证的响应链增量发送,要求网关支持 Responses WebSocket。
|
||||
OPENAI_TRANSPORT=http
|
||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
|
||||
@@ -61,6 +61,8 @@ cd Artemis-Eapil
|
||||
|
||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||
|
||||
模型配置的“传输协议”可选择 **WebSocket(增量传输)**,或设置 `OPENAI_TRANSPORT=websocket`。Flash、Pro、探索及辅助模型的异步请求复用 Responses 连接,历史一致时只上传新增输入;网关需支持 `/v1/responses` 的 WebSocket 升级与响应链。默认 HTTP 保持兼容,详细行为与流量诊断见[传输说明](docs/ai-automation.md#responses-websocket-增量传输)。
|
||||
|
||||
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||
|
||||
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
|
||||
|
||||
@@ -22,6 +22,7 @@ class ModelSettingsUpdate(BaseModel):
|
||||
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||
max_retries: int = Field(default=5, ge=0, le=10)
|
||||
transport: Literal["http", "websocket"] | None = None
|
||||
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
||||
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
@@ -72,6 +73,7 @@ def get_model_settings() -> dict:
|
||||
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||
"transport": settings.OPENAI_TRANSPORT,
|
||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
||||
@@ -102,6 +104,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||
}
|
||||
optional_settings = {
|
||||
"transport": ("OPENAI_TRANSPORT", str),
|
||||
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
||||
@@ -261,10 +264,19 @@ async def test_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
api_base=update.base_url,
|
||||
timeout_seconds=update.timeout_seconds,
|
||||
max_retries=0,
|
||||
transport=update.transport or settings.OPENAI_TRANSPORT,
|
||||
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
|
||||
)
|
||||
model = ModelFactory.create_model(endpoint)
|
||||
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
||||
try:
|
||||
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
||||
finally:
|
||||
await model.http_async_client.aclose()
|
||||
model.http_client.close()
|
||||
if not result.content:
|
||||
raise ValueError("Responses 接口返回空内容,请检查模型配置")
|
||||
return {"success": True, "message": "Responses 连接测试成功", "model": update.model}
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Responses {endpoint.transport.upper()} 连接测试成功",
|
||||
"model": update.model,
|
||||
}
|
||||
@@ -30,6 +30,31 @@ describe('持久化模型与功能设置', () => {
|
||||
http.expectNone('/api/system/model-settings');
|
||||
}));
|
||||
|
||||
it('加载、测试和保存 WebSocket 选择,旧配置默认 HTTP', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/model-settings').flush(config);
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.transport).toBe('http');
|
||||
const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport');
|
||||
selector.value = 'websocket';
|
||||
selector.dispatchEvent(new Event('change'));
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
component.test();
|
||||
const check = http.expectOne('/api/system/model-settings/test');
|
||||
expect(check.request.body.transport).toBe('websocket');
|
||||
check.flush({message: 'Responses WEBSOCKET 连接测试成功'});
|
||||
component.save();
|
||||
const save = http.expectOne('/api/system/model-settings');
|
||||
expect(save.request.body.transport).toBe('websocket');
|
||||
expect(save.request.body.api_key).toBeUndefined();
|
||||
save.flush({...config, transport: 'websocket'});
|
||||
expect(component.transport).toBe('websocket');
|
||||
}));
|
||||
|
||||
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ModelSelectorComponent } from '../model-selector/model-selector.compone
|
||||
import { MODEL_PRESETS } from '../../core/model-presets';
|
||||
|
||||
interface ModelSettings {
|
||||
transport?: 'http' | 'websocket';
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_configured: boolean;
|
||||
@@ -36,6 +37,12 @@ interface ModelSettings {
|
||||
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
||||
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
|
||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
||||
<label for="model-transport">传输协议</label>
|
||||
<select id="model-transport" name="transport" [(ngModel)]="transport" [disabled]="busy() || loading()">
|
||||
<option value="http">HTTP(兼容模式)</option>
|
||||
<option value="websocket">WebSocket(增量传输)</option>
|
||||
</select>
|
||||
<small>WebSocket 可减少重复历史与截图的上传,需要接口支持。保存后对新任务生效。</small>
|
||||
<app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
|
||||
<small>选择预设模型,或选择“自定义模型”填写名称。</small>
|
||||
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
|
||||
@@ -102,6 +109,7 @@ interface ModelSettings {
|
||||
`]
|
||||
})
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
transport: 'http' | 'websocket' = 'http';
|
||||
readonly reasoningValues = REASONING_VALUES;
|
||||
readonly reasoningLabels = REASONING_LABELS;
|
||||
private http = inject(HttpClient);
|
||||
@@ -128,7 +136,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||
}
|
||||
private payload() {
|
||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||
return { base_url: this.baseUrl.trim(), transport: this.transport, model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
||||
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
||||
@@ -136,6 +144,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||
this.transport = config.transport ?? 'http';
|
||||
this.modelPresets = config.model_presets;
|
||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||
|
||||
@@ -62,6 +62,9 @@ class LLM(BaseModel):
|
||||
provider: LLMProvider = "openai"
|
||||
model: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
||||
api_base: str | None = None
|
||||
transport: Literal["http", "websocket"] = Field(
|
||||
default_factory=lambda: settings.OPENAI_TRANSPORT
|
||||
)
|
||||
timeout: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0)
|
||||
max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
|
||||
max_tokens: int | None = Field(default=None, gt=0)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field, SecretStr, model_validator
|
||||
@@ -109,6 +110,7 @@ class Settings(BaseSettings):
|
||||
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
||||
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||
OPENAI_TRANSPORT: Literal["http", "websocket"] = "http"
|
||||
# Environment only; deliberately absent from the saved UI/task configuration.
|
||||
OPENAI_USE_SYSTEM_PROXY: bool = False
|
||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||
|
||||
@@ -390,7 +390,12 @@ async def observe_response_headers(response):
|
||||
"""Correlate allowlisted headers and passively observe bounded SSE frames."""
|
||||
attempt = ACTIVE_ATTEMPT.get()
|
||||
if attempt is not None:
|
||||
attempt.headers(response)
|
||||
websocket = response.extensions.get("artemis_websocket", False)
|
||||
if websocket:
|
||||
# 200/SSE is an in-process SDK facade, not a wire HTTP response.
|
||||
attempt.data.update(headers_at=time.time(), phase="waiting_output")
|
||||
else:
|
||||
attempt.headers(response)
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
||||
if (
|
||||
response.status_code == 200
|
||||
@@ -407,5 +412,7 @@ async def observe_response_headers(response):
|
||||
)
|
||||
response.stream = stream
|
||||
attempt.wire_streams.append(stream)
|
||||
if websocket:
|
||||
stream.observation.data["observation_boundary"] = "websocket_event_sse_adapter"
|
||||
else:
|
||||
attempt.data["wire_streams_truncated"] = True
|
||||
+27
-4
@@ -17,7 +17,7 @@
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -40,6 +40,9 @@ class ModelEndpoint(BaseModel):
|
||||
model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
||||
api_key: str | None = Field(default=None, repr=False)
|
||||
api_base: str | None = None
|
||||
transport: Literal["http", "websocket"] = Field(
|
||||
default_factory=lambda: settings.OPENAI_TRANSPORT
|
||||
)
|
||||
temperature: float | None = None
|
||||
max_tokens: int | None = Field(default=None, gt=0)
|
||||
timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0)
|
||||
@@ -63,6 +66,7 @@ class ModelEndpoint(BaseModel):
|
||||
self.enable_grounding,
|
||||
self.api_base or settings.OPENAI_BASE_URL,
|
||||
settings.OPENAI_USE_SYSTEM_PROXY,
|
||||
self.transport,
|
||||
hashlib.sha256(key.encode()).hexdigest(),
|
||||
)
|
||||
|
||||
@@ -119,13 +123,32 @@ class ModelFactory:
|
||||
"http_client": DefaultHttpxClient(
|
||||
timeout=endpoint.timeout_seconds, **model_http_options()
|
||||
),
|
||||
"http_async_client": DefaultAsyncHttpxClient(
|
||||
}
|
||||
if endpoint.transport == "websocket":
|
||||
import httpx
|
||||
|
||||
from artemis.llm.websocket import ResponsesWebSocketTransport
|
||||
|
||||
# Disable HTTP proxy mounts on this facade: they would bypass the
|
||||
# WebSocket transport. Each actual transport owns its proxy policy.
|
||||
kwargs["http_async_client"] = httpx.AsyncClient(
|
||||
timeout=endpoint.timeout_seconds,
|
||||
trust_env=False,
|
||||
transport=ResponsesWebSocketTransport(
|
||||
timeout=endpoint.timeout_seconds, use_proxy=settings.OPENAI_USE_SYSTEM_PROXY
|
||||
),
|
||||
event_hooks={
|
||||
"request": [observe_request_headers],
|
||||
"response": [observe_response_headers],
|
||||
},
|
||||
)
|
||||
else:
|
||||
kwargs["http_async_client"] = DefaultAsyncHttpxClient(
|
||||
timeout=endpoint.timeout_seconds,
|
||||
**model_http_options(),
|
||||
event_hooks={
|
||||
"request": [observe_request_headers],
|
||||
"response": [observe_response_headers],
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Responses WebSocket transport beneath the existing SDK and safety guards.
|
||||
|
||||
The SDK still serializes complete, authoritative requests. Only this wire boundary
|
||||
omits a verified prefix. No model messages, tool results, or screenshots are lost
|
||||
from local history, so any new connection can start from the full request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from websockets.asyncio.client import connect
|
||||
from websockets.exceptions import ConnectionClosed, InvalidStatus, SecurityError
|
||||
from websockets.protocol import State
|
||||
|
||||
from artemis.llm.diagnostics import ACTIVE_ATTEMPT
|
||||
from artemis.llm.http import model_http_options
|
||||
from artemis.llm.responses_diagnostics import identifier
|
||||
|
||||
IDLE_SECONDS = 60
|
||||
MAX_SESSIONS = 32
|
||||
MAX_CONNECTION_SECONDS = 55 * 60
|
||||
|
||||
|
||||
def _json(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _fingerprint(item):
|
||||
# Assistant message IDs/status are transport metadata; store=false strips
|
||||
# message IDs on replay. Keep every semantic field, including call_id/phase.
|
||||
if isinstance(item, dict):
|
||||
item = dict(item)
|
||||
item.pop("status", None)
|
||||
if item.get("type") == "message" or item.get("role") == "assistant":
|
||||
item.pop("id", None)
|
||||
item.setdefault("type", "message")
|
||||
# Completion events may include token probabilities; LangChain
|
||||
# correctly omits those response-only statistics from replay input.
|
||||
if isinstance(item.get("content"), list):
|
||||
item["content"] = [
|
||||
{k: v for k, v in block.items() if k != "logprobs"}
|
||||
if isinstance(block, dict) and block.get("type") == "output_text"
|
||||
else block
|
||||
for block in item["content"]
|
||||
]
|
||||
if item.get("type") == "function_call" and isinstance(item.get("arguments"), str):
|
||||
with suppress(ValueError):
|
||||
item["arguments"] = json.loads(item["arguments"])
|
||||
return hashlib.sha256(_json(item).encode()).digest()
|
||||
|
||||
|
||||
class _Connect(connect):
|
||||
def process_redirect(self, exc):
|
||||
# Never forward the API credential to a redirected endpoint.
|
||||
if not isinstance(exc, InvalidStatus) or exc.response.status_code not in {
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
307,
|
||||
308,
|
||||
}:
|
||||
return exc
|
||||
return SecurityError("Responses WebSocket redirects are disabled")
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
self.lock = asyncio.Lock()
|
||||
self.users = 0
|
||||
self.connection = None
|
||||
self.opened_at = 0.0
|
||||
self.previous_id = None
|
||||
self.history = ()
|
||||
self.options = None
|
||||
self.timer = None
|
||||
self.handshake = {}
|
||||
|
||||
async def close(self):
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
self.timer = None
|
||||
self.previous_id = None
|
||||
self.history = ()
|
||||
self.options = None
|
||||
connection, self.connection = self.connection, None
|
||||
if connection is not None:
|
||||
with suppress(ConnectionClosed, OSError, TimeoutError):
|
||||
async with asyncio.timeout(1):
|
||||
await connection.close()
|
||||
|
||||
|
||||
class ResponsesWebSocketTransport(httpx.AsyncBaseTransport):
|
||||
"""One serial socket per conversation/role, independent sockets for concurrency."""
|
||||
|
||||
def __init__(self, *, timeout, use_proxy):
|
||||
self.timeout = timeout
|
||||
self.use_proxy = use_proxy
|
||||
self.http = httpx.AsyncClient(timeout=timeout, **model_http_options())
|
||||
self.sessions = OrderedDict()
|
||||
self.cleanup_tasks = set()
|
||||
|
||||
async def aclose(self):
|
||||
sessions = list(self.sessions.values())
|
||||
self.sessions.clear()
|
||||
await asyncio.gather(*(session.close() for session in sessions))
|
||||
if self.cleanup_tasks:
|
||||
await asyncio.gather(*tuple(self.cleanup_tasks), return_exceptions=True)
|
||||
await self.http.aclose()
|
||||
|
||||
def _close_background(self, session):
|
||||
task = asyncio.create_task(session.close())
|
||||
self.cleanup_tasks.add(task)
|
||||
task.add_done_callback(self.cleanup_tasks.discard)
|
||||
|
||||
async def _session(self, request, body):
|
||||
key = body.get("prompt_cache_key")
|
||||
# Header identity prevents credentials, routing, and different gateways
|
||||
# from sharing a socket. Per-request correlation headers aren't affinity.
|
||||
headers = {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k not in {"content-length", "x-client-request-id", "x-client-attempt-id"}
|
||||
}
|
||||
identity = hashlib.sha256(_json([str(request.url), key, headers]).encode()).digest()
|
||||
reusable = isinstance(key, str) and bool(key)
|
||||
if reusable and identity in self.sessions:
|
||||
session = self.sessions.pop(identity)
|
||||
self.sessions[identity] = session
|
||||
else:
|
||||
if len(self.sessions) >= MAX_SESSIONS:
|
||||
for old_key, old in list(self.sessions.items()):
|
||||
if not old.users:
|
||||
del self.sessions[old_key]
|
||||
# Reserve the replacement before yielding: concurrent
|
||||
# requests for its scope must find the same session.
|
||||
self._close_background(old)
|
||||
break
|
||||
session = _Session()
|
||||
reusable = reusable and len(self.sessions) < MAX_SESSIONS
|
||||
if reusable:
|
||||
self.sessions[identity] = session
|
||||
acquired = False
|
||||
session.users += 1
|
||||
try:
|
||||
await session.lock.acquire()
|
||||
acquired = True
|
||||
finally:
|
||||
if not acquired:
|
||||
session.users -= 1
|
||||
self._idle(identity, session, reusable)
|
||||
if session.timer:
|
||||
session.timer.cancel()
|
||||
session.timer = None
|
||||
return identity, session, reusable
|
||||
|
||||
def release(self, identity, session, reusable):
|
||||
session.lock.release()
|
||||
session.users -= 1
|
||||
self._idle(identity, session, reusable)
|
||||
|
||||
def _idle(self, identity, session, reusable):
|
||||
if not reusable or session.users or self.sessions.get(identity) is not session:
|
||||
return
|
||||
|
||||
def expire():
|
||||
if session.users or self.sessions.get(identity) is not session:
|
||||
return
|
||||
del self.sessions[identity]
|
||||
self._close_background(session)
|
||||
|
||||
session.timer = asyncio.get_running_loop().call_later(IDLE_SECONDS, expire)
|
||||
|
||||
async def handle_async_request(self, request):
|
||||
if request.method != "POST" or not request.url.path.rstrip("/").endswith("/responses"):
|
||||
return await self.http.send(request, stream=True)
|
||||
body = json.loads(await request.aread())
|
||||
identity, session, reusable = await self._session(request, body)
|
||||
exchange = _Exchange(self, request, body, identity, session, reusable)
|
||||
stream_handed_off = False
|
||||
try:
|
||||
await exchange.start()
|
||||
first = await exchange.read()
|
||||
if first.get("type") == "error":
|
||||
status = first.get("status", 400)
|
||||
status = status if type(status) is int and 400 <= status <= 599 else 400
|
||||
return httpx.Response(status, json={"error": first.get("error", {})})
|
||||
if not body.get("stream"):
|
||||
event = first
|
||||
while not exchange.done:
|
||||
event = await exchange.read()
|
||||
if event.get("type") == "error":
|
||||
raise httpx.ReadError("Responses WebSocket request failed", request=request)
|
||||
return httpx.Response(
|
||||
200, json=event["response"], extensions={"artemis_websocket": True}
|
||||
)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_EventStream(exchange, first),
|
||||
extensions={"artemis_websocket": True},
|
||||
)
|
||||
stream_handed_off = True
|
||||
return response
|
||||
except InvalidStatus as error:
|
||||
return httpx.Response(
|
||||
error.response.status_code,
|
||||
json={
|
||||
"error": {
|
||||
"message": "Responses WebSocket 升级失败,请检查网关 WebSocket 支持或选择 HTTP。"
|
||||
}
|
||||
},
|
||||
headers={
|
||||
k: v
|
||||
for k, v in error.response.headers.raw_items()
|
||||
if k.lower() in {"retry-after", "x-request-id"}
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if not stream_handed_off:
|
||||
await exchange.aclose()
|
||||
|
||||
|
||||
class _Exchange:
|
||||
def __init__(self, transport, request, body, identity, session, reusable):
|
||||
self.transport = transport
|
||||
self.request = request
|
||||
self.body = body
|
||||
self.identity = identity
|
||||
self.session = session
|
||||
self.reusable = reusable
|
||||
self.done = False
|
||||
self.closed = False
|
||||
self.recovered = False
|
||||
self.output_seen = False
|
||||
self.input = body.get("input")
|
||||
self.input_hashes = (
|
||||
tuple(map(_fingerprint, self.input)) if isinstance(self.input, list) else ()
|
||||
)
|
||||
self.options = hashlib.sha256(
|
||||
_json(
|
||||
{
|
||||
k: v
|
||||
for k, v in body.items()
|
||||
if k not in {"input", "stream", "background", "previous_response_id"}
|
||||
}
|
||||
).encode()
|
||||
).digest()
|
||||
self.explicit_state = bool(body.get("previous_response_id") or body.get("conversation"))
|
||||
self.attempt = ACTIVE_ATTEMPT.get()
|
||||
self.stats = {
|
||||
"mode": "websocket",
|
||||
"full_request_bytes": len(request.content),
|
||||
"sent_payload_bytes": 0,
|
||||
"received_payload_bytes": 0,
|
||||
"create_events": 0,
|
||||
"incremental": False,
|
||||
"reused_input_items": 0,
|
||||
"connection_reused": session.connection is not None,
|
||||
}
|
||||
if self.attempt is not None:
|
||||
self.attempt.data["transport"] = self.stats
|
||||
|
||||
async def start(self):
|
||||
session = self.session
|
||||
if session.connection is not None and (
|
||||
session.connection.state is not State.OPEN
|
||||
or time.monotonic() - session.opened_at >= MAX_CONNECTION_SECONDS
|
||||
):
|
||||
await session.close()
|
||||
self.stats["connection_reused"] = False
|
||||
if session.connection is None:
|
||||
url = self.request.url.copy_with(
|
||||
scheme="wss" if self.request.url.scheme == "https" else "ws"
|
||||
)
|
||||
headers = {
|
||||
k: v
|
||||
for k, v in self.request.headers.items()
|
||||
if k
|
||||
not in {"host", "content-length", "content-type", "connection", "accept-encoding"}
|
||||
}
|
||||
options = {"ssl": model_http_options()["verify"]} if url.scheme == "wss" else {}
|
||||
session.connection = await _Connect(
|
||||
str(url),
|
||||
additional_headers=headers,
|
||||
user_agent_header=None,
|
||||
proxy=True if self.transport.use_proxy else None,
|
||||
open_timeout=self.transport.timeout,
|
||||
close_timeout=1,
|
||||
max_size=64 * 1024 * 1024,
|
||||
**options,
|
||||
)
|
||||
session.opened_at = time.monotonic()
|
||||
session.handshake = {
|
||||
"client_request_id": identifier(headers.get("x-client-request-id")),
|
||||
"client_attempt_id": identifier(headers.get("x-client-attempt-id")),
|
||||
}
|
||||
if self.attempt:
|
||||
self.attempt.data.update(session.handshake, request_header_scope="websocket_handshake")
|
||||
for field, local in (
|
||||
("client_request_id", "request_id"),
|
||||
("client_attempt_id", "attempt_id"),
|
||||
):
|
||||
self.attempt.data[field + "_matches"] = session.handshake.get(
|
||||
field
|
||||
) == self.attempt.data.get(local)
|
||||
self.stats["handshake_http_status"] = 101
|
||||
await self.send(incremental=True)
|
||||
|
||||
async def send(self, *, incremental):
|
||||
session = self.session
|
||||
payload = {k: v for k, v in self.body.items() if k not in {"stream", "background"}}
|
||||
count = len(session.history)
|
||||
can_continue = (
|
||||
incremental
|
||||
and not self.explicit_state
|
||||
and session.previous_id
|
||||
and session.options == self.options
|
||||
and count
|
||||
and len(self.input_hashes) > count
|
||||
and self.input_hashes[:count] == session.history
|
||||
)
|
||||
if can_continue:
|
||||
payload["previous_response_id"] = session.previous_id
|
||||
payload["input"] = self.input[count:]
|
||||
self.stats.update(incremental=True, reused_input_items=count)
|
||||
else:
|
||||
self.stats.update(incremental=False, reused_input_items=0)
|
||||
self.stats["full_context_reason"] = (
|
||||
"explicit_state"
|
||||
if self.explicit_state
|
||||
else "history_or_options_changed"
|
||||
if session.previous_id
|
||||
else "new_connection_or_chain"
|
||||
)
|
||||
payload["type"] = "response.create"
|
||||
encoded = _json(payload)
|
||||
await session.connection.send(encoded)
|
||||
self.stats["sent_payload_bytes"] += len(encoded.encode())
|
||||
self.stats["create_events"] += 1
|
||||
self.stats["payload_bytes_saved"] = max(
|
||||
0, self.stats["full_request_bytes"] - self.stats["sent_payload_bytes"]
|
||||
)
|
||||
if self.attempt:
|
||||
self.attempt.data["request_sent_at"] = time.time()
|
||||
self.attempt.publish_if_due()
|
||||
|
||||
async def read(self):
|
||||
try:
|
||||
async with asyncio.timeout(self.transport.timeout):
|
||||
raw = await self.session.connection.recv()
|
||||
self.stats["received_payload_bytes"] += len(
|
||||
raw.encode() if isinstance(raw, str) else raw
|
||||
)
|
||||
event = json.loads(raw)
|
||||
if not isinstance(event, dict):
|
||||
raise ValueError("Responses WebSocket event is not an object")
|
||||
except ConnectionClosed as error:
|
||||
raise httpx.ReadError(
|
||||
"Responses WebSocket connection closed before completion", request=self.request
|
||||
) from error
|
||||
except (ValueError, UnicodeError) as error:
|
||||
raise httpx.ReadError(
|
||||
"Invalid Responses WebSocket event", request=self.request
|
||||
) from error
|
||||
kind = event.get("type", "")
|
||||
error = event.get("error") or {}
|
||||
if (
|
||||
kind == "error"
|
||||
and error.get("code") == "previous_response_not_found"
|
||||
and self.stats["incremental"]
|
||||
and not self.recovered
|
||||
and not self.output_seen
|
||||
):
|
||||
# An explicit rejection means no model output was generated. Recover
|
||||
# once from the authoritative input; never replay an ambiguous drop.
|
||||
self.recovered = True
|
||||
self.session.previous_id = None
|
||||
self.stats["chain_recovered"] = True
|
||||
await self.send(incremental=False)
|
||||
return await self.read()
|
||||
if kind not in {"error", "response.created", "response.in_progress", "response.queued"}:
|
||||
self.output_seen = True
|
||||
if kind in {
|
||||
"response.completed",
|
||||
"response.failed",
|
||||
"response.incomplete",
|
||||
"response.cancelled",
|
||||
}:
|
||||
self.done = True
|
||||
response = event.get("response") or {}
|
||||
self.session.previous_id = None
|
||||
if (
|
||||
kind == "response.completed"
|
||||
and response.get("status") == "completed"
|
||||
and isinstance(response.get("id"), str)
|
||||
and response["id"]
|
||||
and isinstance(response.get("output"), list)
|
||||
and self.input_hashes
|
||||
and not self.explicit_state
|
||||
):
|
||||
output = [
|
||||
item
|
||||
for item in response["output"]
|
||||
if not (
|
||||
item.get("type") == "reasoning"
|
||||
and not item.get("encrypted_content")
|
||||
and self.body.get("store") is False
|
||||
)
|
||||
]
|
||||
self.session.previous_id = response["id"]
|
||||
self.session.history = self.input_hashes + tuple(map(_fingerprint, output))
|
||||
self.session.options = self.options
|
||||
return event
|
||||
|
||||
async def aclose(self):
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
try:
|
||||
if not self.done or not self.reusable:
|
||||
await self.session.close()
|
||||
finally:
|
||||
self.transport.release(self.identity, self.session, self.reusable)
|
||||
|
||||
|
||||
class _EventStream(httpx.AsyncByteStream):
|
||||
def __init__(self, exchange, first):
|
||||
self.exchange = exchange
|
||||
self.first = first
|
||||
|
||||
async def __aiter__(self):
|
||||
try:
|
||||
event = self.first
|
||||
while True:
|
||||
yield b"data: " + _json(event).encode() + b"\n\n"
|
||||
if self.exchange.done or event.get("type") == "error":
|
||||
return
|
||||
event = await self.exchange.read()
|
||||
finally:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self):
|
||||
await self.exchange.aclose()
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
"default": {
|
||||
"provider": "openai",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||
|
||||
@@ -1174,6 +1174,7 @@ def _resolve_endpoint(
|
||||
else settings.OPENAI_MAX_RETRIES,
|
||||
max_tokens=_get_val(cfg, "max_tokens", int),
|
||||
api_base=_get_val(cfg, "api_base", str),
|
||||
transport=_get_val(cfg, "transport", str) or settings.OPENAI_TRANSPORT,
|
||||
reasoning_effort=_get_val(cfg, "reasoning_effort", str),
|
||||
enable_grounding=_get_val(cfg, "enable_grounding", bool) or False,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
"default": {
|
||||
"provider": "openai",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||
|
||||
@@ -169,6 +169,18 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
|
||||
|
||||
辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 trace 均保留。
|
||||
|
||||
## Responses WebSocket 增量传输
|
||||
|
||||
网页“模型配置 → 传输协议”选择 `WebSocket(增量传输)`,或在 `.env` 设置 `OPENAI_TRANSPORT=websocket`。网页保存对新任务生效;直接编辑 `.env` 后重启相应服务。默认 `http`,旧配置不变。JSON 模型/节点配置可用 `transport` 单独覆盖;MCP 使用 `mobile_config(section="model", changes={"transport":"websocket"}, persist_defaults=true)` 保存,仍须用户明确要求修改默认值。
|
||||
|
||||
异步任务请求在现有 SDK 序列化与完整性保护之下接入 `/v1/responses` WebSocket。首轮发送完整上下文;同一任务(或探索)、角色、模型、路由头及凭据范围内复用连接。只在当前输入严格包含上一轮输入和完整输出、生成选项一致时,发送 `previous_response_id` 与新增输入。仅忽略消息 ID、状态、输出概率等不影响历史语义的响应字段,不按文本相似度截取。工具结果保留 `call_id`,文本、图片、结构化输出与 usage 继续走原有解析和安全检查。同步底层模型调用仍使用 HTTP;Flash、Pro、探索和辅助模型运行路径均为异步。
|
||||
|
||||
本地历史、截图替换或压缩后重新发送当前完整上下文;不会为了续传保留已经移除的旧界面索引。连接重建或服务端明确返回 `previous_response_not_found` 时,从本地完整历史恢复,后者最多补发一次且必须尚未出现输出。连接在输出中途断开时,不在传输层盲目重发,交给现有模型重试预算;取消关闭未完成连接,不采纳半截工具参数或结论。不同会话并行、同一连接串行;连接池最多保留 32 个会话,空闲 60 秒释放,连接满 55 分钟主动重建。`store=false` 保持不变。
|
||||
|
||||
网关必须支持 WebSocket Upgrade、双向长连接、`response.create` 与响应 ID 续接;只透传会话头不能证明支持。握手传递现有授权和路由头,保持 TLS/自定义 CA 验证与 `OPENAI_USE_SYSTEM_PROXY` 策略,拒绝携带凭据跨地址重定向。不支持升级时明确报错,可在网页改回 HTTP,不会静默改成 HTTP 全量请求。
|
||||
|
||||
请求诊断的 `transport` 记录 `full_request_bytes`、`sent_payload_bytes`、`received_payload_bytes`、`payload_bytes_saved`、`incremental`、`reused_input_items`、`connection_reused`、`create_events` 和重建原因。字节数衡量 JSON 应用载荷,不包含 TLS/WebSocket 帧和压缩开销;SDK 内部兼容 SSE 的字节不冒充网络流量或 HTTP 200。握手上的请求标识属于建连尝试,后续轮次以本地请求诊断及响应 ID 关联。减少上传不等于减少模型上下文 token,也不等于缓存命中,usage 仍以服务端实报为准。协议见 [OpenAI 官方说明](https://developers.openai.com/api/docs/guides/websocket-mode)。
|
||||
|
||||
## 指定任务诊断、复盘与管理
|
||||
|
||||
流式请求中的 `click`、`long_press`、`swipe`、`press_key`、`manage_app`、`wait_for_delay` 每次调用参数超过 16,384 字符时,会立即关闭该请求并丢弃未完成输出,避免持续生成异常参数直到超时。该保护只针对小型设备动作,不限制输入文本、笔记、报告或插件参数,也不改变模型思考强度与输出 token 配置。异常最多额外尝试一次(配置为不重试时不重试);仍异常时进入现有备用模型或暂停恢复流程。诊断记录保留 `invalid_output` 分类及 `stream_guard` 中的工具名、实际字符数和保护上限,不保存参数正文;无法据此断定异常来自模型生成还是 API 中转。
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
},
|
||||
{
|
||||
"name": "mobile_config",
|
||||
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
|
||||
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, transport (http/websocket), reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"section": {
|
||||
|
||||
@@ -14,6 +14,7 @@ from mcp_server.base import mcp
|
||||
|
||||
EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports"
|
||||
MODEL_FIELDS = {
|
||||
"transport",
|
||||
"base_url",
|
||||
"model",
|
||||
"timeout_seconds",
|
||||
@@ -186,7 +187,7 @@ async def mobile_config(
|
||||
"""Read configuration or explicitly save non-secret defaults for FUTURE tasks.
|
||||
|
||||
Only set persist_defaults=true when the user requests saved/global configuration.
|
||||
model changes: model (preset or custom), base_url, reasoning_effort
|
||||
model changes: model (preset or custom), base_url, transport (http/websocket), reasoning_effort
|
||||
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
||||
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
||||
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.30.1",
|
||||
"httpx>=0.28.1",
|
||||
"requests>=2.31.0",
|
||||
"websockets>=12.0",
|
||||
"websockets>=15.0",
|
||||
"sseclient-py>=1.8.0",
|
||||
# Image, Video & Multimodal Perception
|
||||
"pillow>=13.0.0",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
## 模式、模型与配置
|
||||
|
||||
网页模型配置和 `mobile_config(section="model")` 提供 `transport=http/websocket`。用户明确要求保存默认值时可设置 `changes={"transport":"websocket"}, persist_defaults=true`;新任务的 Flash、Pro、探索和辅助请求共用此选择。WebSocket 在历史一致时仅上传新增输入,网关须支持 Responses WebSocket;压缩、重连会发送完整上下文,上传减少不能当作 token 减少或缓存命中。不支持协议时应查看错误并确认网关能力,不自动改写用户配置。
|
||||
|
||||
- `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。
|
||||
- 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
|
||||
- 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。
|
||||
|
||||
@@ -21,6 +21,7 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_TIMEOUT_SECONDS",
|
||||
"OPENAI_TRANSPORT",
|
||||
"OPENAI_MAX_RETRIES",
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
|
||||
@@ -50,6 +51,7 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
|
||||
def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
update = service.ModelSettingsUpdate(
|
||||
transport="websocket",
|
||||
base_url="https://gateway.example/v1/",
|
||||
model="my-model",
|
||||
api_key="new-test-secret",
|
||||
@@ -63,6 +65,9 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
)
|
||||
result = service.save_model_settings(update)
|
||||
assert result["api_key_configured"]
|
||||
assert result["transport"] == "websocket"
|
||||
assert dotenv_values(isolated_settings)["OPENAI_TRANSPORT"] == "websocket"
|
||||
assert service.ModelSettingsUpdate(model="test").transport is None
|
||||
assert "new-test-secret" not in repr(result)
|
||||
saved = dotenv_values(isolated_settings)
|
||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||
|
||||
@@ -72,6 +72,8 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
|
||||
payload = request.call_args.kwargs["json"]
|
||||
assert payload["rate_limit_retry_seconds"] == 90
|
||||
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
||||
await tools.mobile_config(changes={"transport": "websocket"}, persist_defaults=True)
|
||||
assert request.call_args.kwargs["json"]["transport"] == "websocket"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
"""Real local WebSocket/SDK round trips; no device, credential, or paid endpoint."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from websockets.asyncio.server import serve
|
||||
|
||||
from artemis.llm.diagnostics import ACTIVE_ATTEMPT
|
||||
from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory
|
||||
from artemis.services.llm import RobustChatModelWrapper
|
||||
|
||||
|
||||
def text_output(text="OK"):
|
||||
return [
|
||||
{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": text, "annotations": [], "logprobs": []}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class Wire:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
self.connections = []
|
||||
self.outputs = []
|
||||
self.reject_previous = False
|
||||
self.drop = False
|
||||
self.stall = False
|
||||
self.status = "completed"
|
||||
self.sent = asyncio.Event()
|
||||
self.gate = None
|
||||
|
||||
async def handle(self, socket):
|
||||
self.connections.append(socket)
|
||||
async for raw in socket:
|
||||
body = json.loads(raw)
|
||||
self.requests.append(body)
|
||||
self.sent.set()
|
||||
if self.reject_previous and body.get("previous_response_id"):
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"status": 400,
|
||||
"error": {"code": "previous_response_not_found"},
|
||||
}
|
||||
)
|
||||
)
|
||||
continue
|
||||
response = {
|
||||
"id": f"resp_{len(self.requests)}",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "in_progress",
|
||||
"model": "test-model",
|
||||
"output": [],
|
||||
}
|
||||
await socket.send(
|
||||
json.dumps({"type": "response.created", "sequence_number": 0, "response": response})
|
||||
)
|
||||
if self.drop:
|
||||
await socket.close()
|
||||
continue
|
||||
if self.stall:
|
||||
await socket.wait_closed()
|
||||
return
|
||||
if self.gate:
|
||||
await self.gate.wait()
|
||||
output = self.outputs.pop(0) if self.outputs else text_output()
|
||||
seq = 0
|
||||
for index, item in enumerate(output):
|
||||
seq += 1
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"sequence_number": seq,
|
||||
"output_index": index,
|
||||
"item": {**item, "content": [], "arguments": ""},
|
||||
}
|
||||
)
|
||||
)
|
||||
for content_index, block in enumerate(item.get("content", [])):
|
||||
if block.get("type") != "output_text":
|
||||
continue
|
||||
seq += 1
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"sequence_number": seq,
|
||||
"output_index": index,
|
||||
"content_index": content_index,
|
||||
"item_id": item["id"],
|
||||
"delta": block["text"],
|
||||
}
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"sequence_number": seq,
|
||||
"output_index": index,
|
||||
"content_index": content_index,
|
||||
"item_id": item["id"],
|
||||
"text": block["text"],
|
||||
}
|
||||
)
|
||||
)
|
||||
seq += 1
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"sequence_number": seq,
|
||||
"output_index": index,
|
||||
"item": item,
|
||||
}
|
||||
)
|
||||
)
|
||||
response.update(
|
||||
status=self.status,
|
||||
output=output,
|
||||
usage={
|
||||
"input_tokens": 20,
|
||||
"output_tokens": 3,
|
||||
"total_tokens": 23,
|
||||
"input_tokens_details": {"cached_tokens": 10},
|
||||
},
|
||||
)
|
||||
await socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": f"response.{self.status}",
|
||||
"sequence_number": seq + 1,
|
||||
"response": response,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wire(monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
|
||||
instance = Wire()
|
||||
async with serve(instance.handle, "127.0.0.1", 0) as server:
|
||||
instance.base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
|
||||
yield instance
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def model(wire, **kwargs):
|
||||
endpoint = ModelEndpoint(
|
||||
model_name="test-model",
|
||||
api_key="test-secret",
|
||||
api_base=wire.base,
|
||||
transport="websocket",
|
||||
max_retries=0,
|
||||
timeout_seconds=2,
|
||||
**kwargs,
|
||||
)
|
||||
value = ModelFactory.create_model(endpoint)
|
||||
try:
|
||||
yield value
|
||||
finally:
|
||||
await value.http_async_client.aclose()
|
||||
value.http_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
async def test_sdk_roundtrip_incremental_history_and_usage(wire, stream):
|
||||
async with model(wire) as raw:
|
||||
messages = [
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Inspect"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64," + "A" * 80000},
|
||||
},
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
async def call(history):
|
||||
if not stream:
|
||||
return await raw.ainvoke(history, prompt_cache_key="task:operator")
|
||||
chunks = [
|
||||
chunk async for chunk in raw.astream(history, prompt_cache_key="task:operator")
|
||||
]
|
||||
return sum(chunks[1:], chunks[0])
|
||||
|
||||
first = await call(messages)
|
||||
ensure_response_complete(first)
|
||||
second = await call([*messages, first, HumanMessage(content="Continue")])
|
||||
ensure_response_complete(second)
|
||||
assert second.usage_metadata["input_token_details"]["cache_read"] == 10
|
||||
assert wire.requests[1].get("previous_response_id") == "resp_1", {
|
||||
"first": first.content,
|
||||
"tail": wire.requests[1]["input"][1:],
|
||||
}
|
||||
assert wire.requests[1]["input"] == [
|
||||
{"type": "message", "role": "user", "content": "Continue"}
|
||||
]
|
||||
assert "stream" not in wire.requests[0]
|
||||
assert wire.requests[0]["type"] == "response.create"
|
||||
assert wire.requests[0]["store"] is False
|
||||
assert len(json.dumps(wire.requests[1])) < len(json.dumps(wire.requests[0])) / 100
|
||||
assert len(wire.connections) == 1
|
||||
assert wire.connections[0].request.path == "/v1/responses"
|
||||
assert wire.connections[0].request.headers["Authorization"] == "Bearer test-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_chain_preserves_call_id(wire):
|
||||
wire.outputs.append(
|
||||
[
|
||||
{
|
||||
"id": "fc_test",
|
||||
"type": "function_call",
|
||||
"call_id": "call_test",
|
||||
"name": "lookup",
|
||||
"arguments": '{"x":1}',
|
||||
"status": "completed",
|
||||
}
|
||||
]
|
||||
)
|
||||
async with model(wire) as raw:
|
||||
messages = [HumanMessage(content="Look up")]
|
||||
first = await raw.ainvoke(messages, prompt_cache_key="task:operator")
|
||||
await raw.ainvoke(
|
||||
[*messages, first, ToolMessage(content="found", tool_call_id="call_test")],
|
||||
prompt_cache_key="task:operator",
|
||||
)
|
||||
assert wire.requests[1]["previous_response_id"] == "resp_1"
|
||||
assert wire.requests[1]["input"] == [
|
||||
{"type": "function_call_output", "call_id": "call_test", "output": "found"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"change", ["history", "options", "scope", "new_connection", "server_closed"]
|
||||
)
|
||||
async def test_chain_resets_on_context_or_connection_change(wire, change):
|
||||
async with model(wire) as raw:
|
||||
messages = [HumanMessage(content="Original")]
|
||||
first = await raw.ainvoke(messages, prompt_cache_key="task:operator")
|
||||
history = [*messages, first, HumanMessage(content="Next")]
|
||||
options = {"prompt_cache_key": "task:operator"}
|
||||
if change == "history":
|
||||
history[0] = HumanMessage(content="Compacted")
|
||||
if change == "options":
|
||||
options["temperature"] = 0.3
|
||||
if change == "scope":
|
||||
options["prompt_cache_key"] = "task:checker"
|
||||
if change == "new_connection":
|
||||
await raw.http_async_client._transport.sessions[
|
||||
next(iter(raw.http_async_client._transport.sessions))
|
||||
].close()
|
||||
if change == "server_closed":
|
||||
await wire.connections[0].close()
|
||||
await wire.connections[0].wait_closed()
|
||||
await raw.ainvoke(history, **options)
|
||||
assert "previous_response_id" not in wire.requests[1]
|
||||
assert len(wire.requests[1]["input"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uncached_id_recovers_once_with_full_context(wire):
|
||||
async with model(wire) as raw:
|
||||
history = [HumanMessage(content="First")]
|
||||
first = await raw.ainvoke(history, prompt_cache_key="task:operator")
|
||||
wire.reject_previous = True
|
||||
result = await raw.ainvoke(
|
||||
[*history, first, HumanMessage(content="Next")], prompt_cache_key="task:operator"
|
||||
)
|
||||
ensure_response_complete(result)
|
||||
assert len(wire.requests) == 3
|
||||
assert wire.requests[1]["previous_response_id"] == "resp_1"
|
||||
assert "previous_response_id" not in wire.requests[2]
|
||||
assert len(wire.requests[2]["input"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_affinity_never_shares_connections(wire):
|
||||
async with model(wire) as raw:
|
||||
history = [HumanMessage(content="First")]
|
||||
first = await raw.ainvoke(history)
|
||||
await raw.ainvoke([*history, first, HumanMessage(content="Next")])
|
||||
assert len(wire.connections) == 2
|
||||
assert "previous_response_id" not in wire.requests[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_closes_socket_and_next_call_starts_full(wire):
|
||||
async with model(wire) as raw:
|
||||
wire.stall = True
|
||||
task = asyncio.create_task(
|
||||
raw.ainvoke([HumanMessage(content="Wait")], prompt_cache_key="task:operator")
|
||||
)
|
||||
await asyncio.wait_for(wire.sent.wait(), 2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
|
||||
wire.stall = False
|
||||
await raw.ainvoke([HumanMessage(content="Next")], prompt_cache_key="task:operator")
|
||||
assert len(wire.connections) == 2
|
||||
assert "previous_response_id" not in wire.requests[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_never_silently_resends_model_request(wire):
|
||||
async with model(wire) as raw:
|
||||
wire.drop = True
|
||||
with pytest.raises(Exception, match="connection|Connection"):
|
||||
await raw.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
|
||||
assert len(wire.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_result_does_not_advance_chain(wire):
|
||||
async with model(wire) as raw:
|
||||
wire.status = "incomplete"
|
||||
first = await raw.ainvoke([HumanMessage(content="First")], prompt_cache_key="task:operator")
|
||||
with pytest.raises(ResponseIntegrityError):
|
||||
ensure_response_complete(first)
|
||||
wire.status = "completed"
|
||||
await raw.ainvoke(
|
||||
[HumanMessage(content="First"), first, HumanMessage(content="Next")],
|
||||
prompt_cache_key="task:operator",
|
||||
)
|
||||
assert "previous_response_id" not in wire.requests[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_measure_actual_wire_payload_without_logging_content(wire):
|
||||
class Attempt:
|
||||
data = {}
|
||||
|
||||
def publish_if_due(self):
|
||||
pass
|
||||
|
||||
attempt = Attempt()
|
||||
async with model(wire) as raw:
|
||||
# Disable the unrelated HTTP trace hook: this checks the transport's
|
||||
# own byte counters, alongside the real SDK request serialization.
|
||||
raw.http_async_client.event_hooks = {"request": [], "response": []}
|
||||
first = await raw.ainvoke(
|
||||
[HumanMessage(content="A" * 5000)], prompt_cache_key="task:operator"
|
||||
)
|
||||
token = ACTIVE_ATTEMPT.set(attempt)
|
||||
try:
|
||||
await raw.ainvoke(
|
||||
[HumanMessage(content="A" * 5000), first, HumanMessage(content="Next")],
|
||||
prompt_cache_key="task:operator",
|
||||
)
|
||||
finally:
|
||||
ACTIVE_ATTEMPT.reset(token)
|
||||
stats = deepcopy(attempt.data["transport"])
|
||||
assert stats["incremental"] is True
|
||||
assert stats["sent_payload_bytes"] == len(
|
||||
json.dumps(
|
||||
wire.requests[1], ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
||||
).encode()
|
||||
)
|
||||
assert stats["payload_bytes_saved"] > 4000
|
||||
assert "AAAA" not in json.dumps(stats)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_rejects_upgrade_with_explicit_error(wire):
|
||||
import openai
|
||||
from websockets.http11 import Response
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
async with serve(
|
||||
wire.handle,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
process_request=lambda *_: Response(404, "Not Found", Headers(), b""),
|
||||
) as server:
|
||||
wire.base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
|
||||
async with model(wire) as raw:
|
||||
with pytest.raises(openai.NotFoundError, match="WebSocket"):
|
||||
await raw.ainvoke([HumanMessage(content="Test")])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_retains_stream_guards_and_diagnostic_events(wire, monkeypatch):
|
||||
from artemis.services import llm
|
||||
from tests.unit.test_responses_wire_lifecycle import Engine
|
||||
|
||||
engine = Engine()
|
||||
monkeypatch.setattr(llm, "_get_current_data_engine", lambda: engine)
|
||||
async with model(wire) as raw:
|
||||
wrapper = RobustChatModelWrapper(
|
||||
raw,
|
||||
endpoint=ModelEndpoint(model_name="test-model", transport="websocket", max_retries=0),
|
||||
)
|
||||
result = await wrapper.ainvoke(
|
||||
[HumanMessage(content="Test")], prompt_cache_key="task:operator"
|
||||
)
|
||||
ensure_response_complete(result)
|
||||
assert engine.data["transport"]["mode"] == "websocket"
|
||||
assert engine.data["transport"]["received_payload_bytes"] > 0
|
||||
assert engine.data.get("http_status") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_scopes_keep_independent_response_chains(wire):
|
||||
async with model(wire) as raw:
|
||||
|
||||
async def conversation(scope):
|
||||
history = [HumanMessage(content=f"First {scope}")]
|
||||
first = await raw.ainvoke(history, prompt_cache_key=scope)
|
||||
await raw.ainvoke(
|
||||
[*history, first, HumanMessage(content=f"Next {scope}")], prompt_cache_key=scope
|
||||
)
|
||||
return scope, first.response_metadata["id"]
|
||||
|
||||
results = await asyncio.gather(
|
||||
conversation("task-a:operator"), conversation("task-b:operator")
|
||||
)
|
||||
assert len(wire.connections) == 2
|
||||
for scope, response_id in results:
|
||||
calls = [item for item in wire.requests if item["prompt_cache_key"] == scope]
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["previous_response_id"] == response_id
|
||||
assert len(calls[1]["input"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_connections_are_closed_and_removed(wire, monkeypatch):
|
||||
from artemis.llm import websocket
|
||||
|
||||
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.01)
|
||||
async with model(wire) as raw:
|
||||
await raw.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
|
||||
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
|
||||
assert not raw.http_async_client._transport.sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, monkeypatch):
|
||||
from artemis.llm import websocket
|
||||
|
||||
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.05)
|
||||
wire.gate = asyncio.Event()
|
||||
async with model(wire) as raw:
|
||||
history = [HumanMessage(content="First")]
|
||||
first = asyncio.create_task(raw.ainvoke(history, prompt_cache_key="task:operator"))
|
||||
await asyncio.wait_for(wire.sent.wait(), 2)
|
||||
queued = asyncio.create_task(raw.ainvoke(history, prompt_cache_key="task:operator"))
|
||||
async with asyncio.timeout(2):
|
||||
while next(iter(raw.http_async_client._transport.sessions.values())).users != 2:
|
||||
await asyncio.sleep(0)
|
||||
queued.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await queued
|
||||
wire.gate.set()
|
||||
result = await first
|
||||
await raw.ainvoke(
|
||||
[*history, result, HumanMessage(content="Next")], prompt_cache_key="task:operator"
|
||||
)
|
||||
assert len(wire.connections) == 1
|
||||
assert len(wire.requests) == 2
|
||||
assert wire.requests[1]["previous_response_id"] == result.response_metadata["id"]
|
||||
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
|
||||
assert not raw.http_async_client._transport.sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_eviction_does_not_split_concurrent_scope(wire, monkeypatch):
|
||||
from artemis.llm import websocket
|
||||
|
||||
monkeypatch.setattr(websocket, "MAX_SESSIONS", 1)
|
||||
closing, resume = asyncio.Event(), asyncio.Event()
|
||||
async with model(wire) as raw:
|
||||
await raw.ainvoke([HumanMessage(content="Old")], prompt_cache_key="old")
|
||||
old = next(iter(raw.http_async_client._transport.sessions.values()))
|
||||
close = old.close
|
||||
|
||||
async def delayed_close():
|
||||
closing.set()
|
||||
await resume.wait()
|
||||
await close()
|
||||
|
||||
monkeypatch.setattr(old, "close", delayed_close)
|
||||
first = asyncio.create_task(
|
||||
raw.ainvoke([HumanMessage(content="New first")], prompt_cache_key="new")
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(closing.wait(), 2)
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
first,
|
||||
raw.ainvoke([HumanMessage(content="New second")], prompt_cache_key="new"),
|
||||
),
|
||||
2,
|
||||
)
|
||||
assert len(wire.connections) == 2
|
||||
assert len(wire.requests) == 3
|
||||
finally:
|
||||
resume.set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_output_uses_same_websocket_path(wire):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Result(BaseModel):
|
||||
ok: bool
|
||||
|
||||
wire.outputs.append(
|
||||
[
|
||||
{
|
||||
"id": "fc_test",
|
||||
"type": "function_call",
|
||||
"call_id": "call_test",
|
||||
"name": "Result",
|
||||
"arguments": '{"ok":true}',
|
||||
"status": "completed",
|
||||
}
|
||||
]
|
||||
)
|
||||
async with model(wire) as raw:
|
||||
result = (
|
||||
await RobustChatModelWrapper(
|
||||
raw, endpoint=ModelEndpoint(transport="websocket", max_retries=0)
|
||||
)
|
||||
.with_structured_output(Result)
|
||||
.ainvoke([HumanMessage(content="Test")])
|
||||
)
|
||||
assert result.ok is True
|
||||
assert len(wire.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_tool_arguments_are_rejected_before_use(wire, monkeypatch):
|
||||
from artemis.services import llm
|
||||
from artemis.llm.reliability import LLMExhaustedError
|
||||
|
||||
monkeypatch.setattr(llm, "_get_current_data_engine", lambda: None)
|
||||
wire.outputs.append(
|
||||
[
|
||||
{
|
||||
"id": "fc_test",
|
||||
"type": "function_call",
|
||||
"call_id": "call_test",
|
||||
"name": "lookup",
|
||||
"arguments": '{"x":',
|
||||
"status": "completed",
|
||||
}
|
||||
]
|
||||
)
|
||||
async with model(wire) as raw:
|
||||
wrapper = RobustChatModelWrapper(
|
||||
raw, endpoint=ModelEndpoint(transport="websocket", max_retries=0)
|
||||
)
|
||||
with pytest.raises(LLMExhaustedError):
|
||||
await wrapper.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
|
||||
assert len(wire.requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_does_not_forward_authorization(wire):
|
||||
from websockets.http11 import Response
|
||||
from websockets.datastructures import Headers
|
||||
|
||||
async with serve(
|
||||
wire.handle,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
process_request=lambda *_: Response(
|
||||
307,
|
||||
"Redirect",
|
||||
Headers({"Location": wire.base.replace("http://", "ws://") + "/responses"}),
|
||||
b"",
|
||||
),
|
||||
) as server:
|
||||
redirect_base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
|
||||
original_base, wire.base = wire.base, redirect_base
|
||||
try:
|
||||
async with model(wire) as raw:
|
||||
with pytest.raises(Exception, match="redirects are disabled"):
|
||||
await raw.ainvoke([HumanMessage(content="Test")])
|
||||
assert not wire.connections
|
||||
finally:
|
||||
wire.base = original_base
|
||||
@@ -369,7 +369,7 @@ requires-dist = [
|
||||
{ name = "uiautomator2", specifier = ">=3.5.0" },
|
||||
{ name = "uuid-utils", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.1" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
{ name = "websockets", specifier = ">=15.0" },
|
||||
{ name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'", specifier = ">=2.0.1,<3" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
Reference in new issue
Block a user