feat(llm): support incremental Responses WebSocket transport
This commit is contained in:
1 parent
73adcf25a7
commit
2a3f43fb60
22 files changed
+1174
-10
No files matched your search
@@ -9,6 +9,8 @@ OPENAI_TIMEOUT_SECONDS=300
|
|||||||
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
|
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
|
||||||
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
|
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
|
||||||
OPENAI_USE_SYSTEM_PROXY=false
|
OPENAI_USE_SYSTEM_PROXY=false
|
||||||
|
# http 保持兼容;websocket 复用连接并按已验证的响应链增量发送,要求网关支持 Responses WebSocket。
|
||||||
|
OPENAI_TRANSPORT=http
|
||||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.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`,重启后对新任务生效。
|
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||||
|
|
||||||
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
|
首页“功能配置”提供“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.
|
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
max_retries: int = Field(default=5, ge=0, le=10)
|
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)
|
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||||
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
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)
|
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),
|
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
|
"transport": settings.OPENAI_TRANSPORT,
|
||||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||||
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
"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,
|
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||||
}
|
}
|
||||||
optional_settings = {
|
optional_settings = {
|
||||||
|
"transport": ("OPENAI_TRANSPORT", str),
|
||||||
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||||
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||||
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", 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,
|
api_base=update.base_url,
|
||||||
timeout_seconds=update.timeout_seconds,
|
timeout_seconds=update.timeout_seconds,
|
||||||
max_retries=0,
|
max_retries=0,
|
||||||
|
transport=update.transport or settings.OPENAI_TRANSPORT,
|
||||||
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
|
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
|
||||||
)
|
)
|
||||||
model = ModelFactory.create_model(endpoint)
|
model = ModelFactory.create_model(endpoint)
|
||||||
|
try:
|
||||||
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
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:
|
if not result.content:
|
||||||
raise ValueError("Responses 接口返回空内容,请检查模型配置")
|
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');
|
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('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
||||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ModelSelectorComponent } from '../model-selector/model-selector.compone
|
|||||||
import { MODEL_PRESETS } from '../../core/model-presets';
|
import { MODEL_PRESETS } from '../../core/model-presets';
|
||||||
|
|
||||||
interface ModelSettings {
|
interface ModelSettings {
|
||||||
|
transport?: 'http' | 'websocket';
|
||||||
base_url: string;
|
base_url: string;
|
||||||
model: string;
|
model: string;
|
||||||
api_key_configured: boolean;
|
api_key_configured: boolean;
|
||||||
@@ -36,6 +37,12 @@ interface ModelSettings {
|
|||||||
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
||||||
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
|
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
|
||||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
<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()" />
|
<app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
|
||||||
<small>选择预设模型,或选择“自定义模型”填写名称。</small>
|
<small>选择预设模型,或选择“自定义模型”填写名称。</small>
|
||||||
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
|
<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 {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
|
transport: 'http' | 'websocket' = 'http';
|
||||||
readonly reasoningValues = REASONING_VALUES;
|
readonly reasoningValues = REASONING_VALUES;
|
||||||
readonly reasoningLabels = REASONING_LABELS;
|
readonly reasoningLabels = REASONING_LABELS;
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -128,7 +136,7 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||||
}
|
}
|
||||||
private payload() {
|
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,
|
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(),
|
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||||
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
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 {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
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.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.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;
|
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ class LLM(BaseModel):
|
|||||||
provider: LLMProvider = "openai"
|
provider: LLMProvider = "openai"
|
||||||
model: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
model: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
||||||
api_base: str | None = None
|
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)
|
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_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
|
||||||
max_tokens: int | None = Field(default=None, gt=0)
|
max_tokens: int | None = Field(default=None, gt=0)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from pydantic import Field, SecretStr, model_validator
|
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_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
||||||
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
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.
|
# Environment only; deliberately absent from the saved UI/task configuration.
|
||||||
OPENAI_USE_SYSTEM_PROXY: bool = False
|
OPENAI_USE_SYSTEM_PROXY: bool = False
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||||
|
|||||||
@@ -390,6 +390,11 @@ async def observe_response_headers(response):
|
|||||||
"""Correlate allowlisted headers and passively observe bounded SSE frames."""
|
"""Correlate allowlisted headers and passively observe bounded SSE frames."""
|
||||||
attempt = ACTIVE_ATTEMPT.get()
|
attempt = ACTIVE_ATTEMPT.get()
|
||||||
if attempt is not None:
|
if attempt is not None:
|
||||||
|
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)
|
attempt.headers(response)
|
||||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
||||||
if (
|
if (
|
||||||
@@ -407,5 +412,7 @@ async def observe_response_headers(response):
|
|||||||
)
|
)
|
||||||
response.stream = stream
|
response.stream = stream
|
||||||
attempt.wire_streams.append(stream)
|
attempt.wire_streams.append(stream)
|
||||||
|
if websocket:
|
||||||
|
stream.observation.data["observation_boundary"] = "websocket_event_sse_adapter"
|
||||||
else:
|
else:
|
||||||
attempt.data["wire_streams_truncated"] = True
|
attempt.data["wire_streams_truncated"] = True
|
||||||
+27
-4
@@ -17,7 +17,7 @@
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from langchain_core.language_models.chat_models import BaseChatModel
|
from langchain_core.language_models.chat_models import BaseChatModel
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -40,6 +40,9 @@ class ModelEndpoint(BaseModel):
|
|||||||
model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
|
||||||
api_key: str | None = Field(default=None, repr=False)
|
api_key: str | None = Field(default=None, repr=False)
|
||||||
api_base: str | None = None
|
api_base: str | None = None
|
||||||
|
transport: Literal["http", "websocket"] = Field(
|
||||||
|
default_factory=lambda: settings.OPENAI_TRANSPORT
|
||||||
|
)
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
max_tokens: int | None = Field(default=None, gt=0)
|
max_tokens: int | None = Field(default=None, gt=0)
|
||||||
timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, 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.enable_grounding,
|
||||||
self.api_base or settings.OPENAI_BASE_URL,
|
self.api_base or settings.OPENAI_BASE_URL,
|
||||||
settings.OPENAI_USE_SYSTEM_PROXY,
|
settings.OPENAI_USE_SYSTEM_PROXY,
|
||||||
|
self.transport,
|
||||||
hashlib.sha256(key.encode()).hexdigest(),
|
hashlib.sha256(key.encode()).hexdigest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,13 +123,32 @@ class ModelFactory:
|
|||||||
"http_client": DefaultHttpxClient(
|
"http_client": DefaultHttpxClient(
|
||||||
timeout=endpoint.timeout_seconds, **model_http_options()
|
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,
|
timeout=endpoint.timeout_seconds,
|
||||||
**model_http_options(),
|
**model_http_options(),
|
||||||
event_hooks={
|
event_hooks={
|
||||||
"request": [observe_request_headers],
|
"request": [observe_request_headers],
|
||||||
"response": [observe_response_headers],
|
"response": [observe_response_headers],
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
}
|
|
||||||
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
|
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": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
|
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||||
|
|||||||
@@ -1174,6 +1174,7 @@ def _resolve_endpoint(
|
|||||||
else settings.OPENAI_MAX_RETRIES,
|
else settings.OPENAI_MAX_RETRIES,
|
||||||
max_tokens=_get_val(cfg, "max_tokens", int),
|
max_tokens=_get_val(cfg, "max_tokens", int),
|
||||||
api_base=_get_val(cfg, "api_base", str),
|
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),
|
reasoning_effort=_get_val(cfg, "reasoning_effort", str),
|
||||||
enable_grounding=_get_val(cfg, "enable_grounding", bool) or False,
|
enable_grounding=_get_val(cfg, "enable_grounding", bool) or False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{
|
{
|
||||||
"default": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
|
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||||
|
|||||||
@@ -169,6 +169,18 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
|
|||||||
|
|
||||||
辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 trace 均保留。
|
辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 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 中转。
|
流式请求中的 `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",
|
"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": {
|
"inputSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"section": {
|
"section": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from mcp_server.base import mcp
|
|||||||
|
|
||||||
EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports"
|
EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports"
|
||||||
MODEL_FIELDS = {
|
MODEL_FIELDS = {
|
||||||
|
"transport",
|
||||||
"base_url",
|
"base_url",
|
||||||
"model",
|
"model",
|
||||||
"timeout_seconds",
|
"timeout_seconds",
|
||||||
@@ -186,7 +187,7 @@ async def mobile_config(
|
|||||||
"""Read configuration or explicitly save non-secret defaults for FUTURE tasks.
|
"""Read configuration or explicitly save non-secret defaults for FUTURE tasks.
|
||||||
|
|
||||||
Only set persist_defaults=true when the user requests saved/global configuration.
|
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,
|
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
||||||
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
||||||
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ dependencies = [
|
|||||||
"uvicorn[standard]>=0.30.1",
|
"uvicorn[standard]>=0.30.1",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
"websockets>=12.0",
|
"websockets>=15.0",
|
||||||
"sseclient-py>=1.8.0",
|
"sseclient-py>=1.8.0",
|
||||||
# Image, Video & Multimodal Perception
|
# Image, Video & Multimodal Perception
|
||||||
"pillow>=13.0.0",
|
"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 模型。
|
- `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 默认值。
|
- 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
|
||||||
- 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。
|
- 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ def isolated_settings(monkeypatch, tmp_path):
|
|||||||
"OPENAI_MODEL",
|
"OPENAI_MODEL",
|
||||||
"OPENAI_BASE_URL",
|
"OPENAI_BASE_URL",
|
||||||
"OPENAI_TIMEOUT_SECONDS",
|
"OPENAI_TIMEOUT_SECONDS",
|
||||||
|
"OPENAI_TRANSPORT",
|
||||||
"OPENAI_MAX_RETRIES",
|
"OPENAI_MAX_RETRIES",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
"OPENAI_RATE_LIMIT_RETRY_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):
|
def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||||
update = service.ModelSettingsUpdate(
|
update = service.ModelSettingsUpdate(
|
||||||
|
transport="websocket",
|
||||||
base_url="https://gateway.example/v1/",
|
base_url="https://gateway.example/v1/",
|
||||||
model="my-model",
|
model="my-model",
|
||||||
api_key="new-test-secret",
|
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)
|
result = service.save_model_settings(update)
|
||||||
assert result["api_key_configured"]
|
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)
|
assert "new-test-secret" not in repr(result)
|
||||||
saved = dotenv_values(isolated_settings)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
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"]
|
payload = request.call_args.kwargs["json"]
|
||||||
assert payload["rate_limit_retry_seconds"] == 90
|
assert payload["rate_limit_retry_seconds"] == 90
|
||||||
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
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
|
@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 = "uiautomator2", specifier = ">=3.5.0" },
|
||||||
{ name = "uuid-utils", specifier = ">=0.9.0" },
|
{ name = "uuid-utils", specifier = ">=0.9.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.1" },
|
{ 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" },
|
{ name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'", specifier = ">=2.0.1,<3" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|||||||
Reference in new issue
Block a user