feat(llm): support incremental Responses WebSocket transport

This commit is contained in:
chenmingxuan committed 2026-11-04 17:15:32 +08:00
1 parent 73adcf25a7
commit 2a3f43fb60
22 files changed
+1176 -12

No files matched your search

@@ -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;