fix(multimodal): 冻结推理环境并隔离迟到导入错误

This commit is contained in:
2026-09-05 02:09:15 +08:00
parent d5eff47417
commit 6235118141
6 changed files with 29 additions and 11 deletions
+6 -4
View File
@@ -69,9 +69,10 @@ def configure(request):
return request
def interpreter():
def interpreter(config=None):
from app.local_models import components
if not os.getenv("APP_MODEL_PYTHON") and configuration().device == "cuda" and components.ready():
requested_device = (config or configuration()).device
if not os.getenv("APP_MODEL_PYTHON") and requested_device == "cuda" and components.ready():
return components.ROOT / "Scripts/python.exe"
return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))))
@@ -160,7 +161,8 @@ class Runtime:
async def _execute(self, key, operation, payload, config, diagnostics):
if read_state(key)["status"] != "installed":
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先下载本地模型。")
if not interpreter().is_file():
executable = interpreter(config)
if not executable.is_file():
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先安装本地模型运行环境。")
from app.services.usage_service import UsageAttempt
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
@@ -170,7 +172,7 @@ class Runtime:
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
"PYTHONIOENCODING": "utf-8"}
args = (str(interpreter()), str(Path(__file__).with_name("worker.py")))
args = (str(executable), str(Path(__file__).with_name("worker.py")))
options = {"env": env, "limit": 16 * 1024 * 1024,
**({"creationflags": 0x08000000} if os.name == "nt" else {})}
try:
+2 -2
View File
@@ -47,7 +47,7 @@ def test_local_model_missing_is_explicit():
def test_cancel_reaps_active_model_process(monkeypatch):
import app.local_models.runtime as module
monkeypatch.setattr(module,'read_state',lambda key:{'status':'installed'})
monkeypatch.setattr(module,'interpreter',lambda:Path(sys.executable))
monkeypatch.setattr(module,'interpreter',lambda *_:Path(sys.executable))
class Input:
def write(self, value):
request = json.loads(value)
@@ -92,7 +92,7 @@ def test_subprocess_fallback_runs_and_reaps_real_worker(monkeypatch, tmp_path, c
import app.local_models.process as process_module
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
worker = tmp_path / 'worker.py'
worker.write_text(
'import json,sys,time\n'
@@ -21,7 +21,7 @@ def test_cuda_retries_only_device_failures_in_reaped_process(monkeypatch, code,
from app.services.usage_service import connection
monkeypatch.setattr(module, 'configuration', lambda: module.RuntimeConfig(device='cuda'))
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
monkeypatch.setattr(module, 'interpreter', lambda: Path(sys.executable))
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
events = []
class Process:
+3 -2
View File
@@ -79,7 +79,8 @@ def test_interpreter_keeps_cpu_default_and_respects_explicit_override(monkeypatc
(components.ROOT / 'ready.json').write_text('{}')
monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cpu'))
assert runtime.interpreter() != python
monkeypatch.setattr(runtime, 'configuration', lambda: runtime.RuntimeConfig(device='cuda'))
assert runtime.interpreter() == python
# A queued attempt keeps its frozen device even after the saved setting changes.
assert runtime.interpreter(runtime.RuntimeConfig(device='cuda')) == python
assert runtime.interpreter(runtime.RuntimeConfig(device='cpu')) != python
monkeypatch.setenv('APP_MODEL_PYTHON', 'explicit-python.exe')
assert str(runtime.interpreter()) == 'explicit-python.exe'
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
@@ -38,6 +38,21 @@ it('ignores an imported configuration that finishes after a newer edit', async (
wrapper.unmount()
})
it('ignores an old import failure after a newer edit', async () => {
let fail!: (reason: Error) => void
vi.mocked(apiClient.post).mockReturnValue(new Promise((_resolve, reject) => { fail = reject }))
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', {value:[new File(['{}'], 'old.json')], configurable:true})
await input.trigger('change')
await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click')
fail(new Error('旧导入失败'))
await flushPromises()
expect(wrapper.text()).not.toContain('旧导入失败')
expect(wrapper.findAll('textarea')).toHaveLength(1)
wrapper.unmount()
})
it('restores defaults even from an invalid draft and reflects replacement configurations', async () => {
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[{capability:'chat', body:{enable_thinking:false}}]}})
await wrapper.get('textarea').setValue('{invalid')
@@ -51,7 +51,7 @@ async function importRules(event: Event) {
if (current !== generation) return
rules.value = validated.request_overrides.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
publish()
} catch(e) { transferError.value = (e as Error).message }
} catch(e) { if (current === generation) transferError.value = (e as Error).message }
}
async function exportRules() {
transferError.value = ''