fix(translate): Kilo API response_format, reasoning_effort skip, structured_outputs fallback, refusal handling

- Enable response_format (json_object) for all providers including Kilo/OpenRouter
- Skip reasoning_effort for Kilo/OpenRouter (returns 400 — mandatory reasoning)
- Add structured_outputs fallback: retry once without response_format if upstream
  provider (e.g. StepFun) rejects it with 'structured_outputs is not supported'
- Handle model refusal field (refusal) instead of treating as empty content
- Fix None-handling guards for message/finish_reason/content fields
- Apply same fixes to both preview.py and executor.py _call_openai_compatible
- Connect orphaned TermCorrectionPopup, BulkReplaceModal, BulkCorrectionSidebar
This commit is contained in:
2026-05-15 18:09:00 +03:00
parent 0fbf8f65bf
commit fdf48491a1
18 changed files with 1888 additions and 336 deletions

View File

@@ -138,6 +138,10 @@ async def fetch_models(
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid provider_type: {provider_type_str}")
# Release DB connection before the network call to avoid idle-in-transaction
# blocking other queries while we wait for the LLM API response.
db.close()
client = LLMClient(
provider_type=provider_type,
api_key=api_key or "sk-placeholder",

View File

@@ -30,33 +30,42 @@ from ._router import router
# @PRE: User has translate.job.execute permission.
# @POST: Returns the created translation run.
@router.post("/jobs/{job_id}/run", status_code=status.HTTP_201_CREATED)
async def run_translation(
def run_translation(
job_id: str,
full_translation: bool = Query(False, description="Translate ALL rows (skip new-key-only filter)"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Execute a translation job (trigger a run)."""
logger.reason(f"run_translation — Job: {job_id}, User: {current_user.username}", extra={"src": "translate_routes"})
"""Execute a translation job (trigger a run).
- Normal run (default): translates only new/changed rows
- Full translation (full_translation=true): re-translates ALL rows from the datasource
"""
logger.reason(
f"run_translation — Job: {job_id}, User: {current_user.username}, full: {full_translation}",
extra={"src": "translate_routes"},
)
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.start_run(job_id=job_id, is_scheduled=False)
run = orch.start_run(job_id=job_id, is_scheduled=False, full_translation=full_translation)
# The request-scoped db session will be closed after this handler returns.
# The background thread must use its OWN session to avoid operating on a
# closed or expunged session.
import threading
def _background_execute():
bg_db = SessionLocal()
from ....models.translate import TranslationRun as TRModel
bg_db = None
try:
bg_db = SessionLocal()
bg_orch = TranslationOrchestrator(
bg_db, config_manager,
current_user.username if current_user else None,
)
# Re-fetch the run within the background session to get a fresh,
# attached object
from ....models.translate import TranslationRun as TRModel
bg_run = bg_db.query(TRModel).filter(TRModel.id == run.id).first()
if bg_run is None:
logger.explore(
@@ -139,7 +148,8 @@ async def run_translation(
},
)
finally:
bg_db.close()
if bg_db is not None:
bg_db.close()
threading.Thread(
target=_background_execute,
@@ -159,7 +169,7 @@ async def run_translation(
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated translation run.
@router.post("/runs/{run_id}/retry")
async def retry_run(
def retry_run(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
@@ -185,7 +195,7 @@ async def retry_run(
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated run.
@router.post("/runs/{run_id}/retry-insert")
async def retry_insert(
def retry_insert(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
@@ -211,7 +221,7 @@ async def retry_insert(
# @PRE: User has translate.job.execute permission.
# @POST: Run is cancelled.
@router.post("/runs/{run_id}/cancel")
async def cancel_run(
def cancel_run(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
@@ -234,7 +244,7 @@ async def cancel_run(
# @PRE: User has translate.history.view permission.
# @POST: Returns list of runs.
@router.get("/jobs/{job_id}/runs")
async def get_run_history(
def get_run_history(
job_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -259,7 +269,7 @@ async def get_run_history(
# @PRE: User has translate.history.view permission.
# @POST: Returns run details with statistics.
@router.get("/runs/{run_id}")
async def get_run_status(
def get_run_status(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
@@ -281,7 +291,7 @@ async def get_run_status(
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated records.
@router.get("/runs/{run_id}/records")
async def get_run_records(
def get_run_records(
run_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
@@ -310,7 +320,7 @@ async def get_run_records(
# @PRE: User has translate.job.view permission.
# @POST: Returns list of batches.
@router.get("/runs/{run_id}/batches")
async def get_batches(
def get_batches(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
@@ -356,7 +366,7 @@ async def get_batches(
# @POST: TranslationLanguage.source_language_detected is updated; language_overridden is set to True.
# @SIDE_EFFECT: DB write.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language")
async def override_detected_language(
def override_detected_language(
run_id: str,
record_id: str,
language_code: str,
@@ -434,7 +444,7 @@ async def override_detected_language(
# @PRE: User has translate.job.execute permission. Run, record, and language entry exist.
# @POST: TranslationLanguage.final_value and user_edit are updated. Optional dictionary submission.
@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}")
async def inline_edit_translation(
def inline_edit_translation(
run_id: str,
record_id: str,
language_code: str,
@@ -483,7 +493,7 @@ async def inline_edit_translation(
# @PRE: User has translate.job.execute permission. Run exists.
# @POST: If preview=false, matching translations are updated. Optional dictionary submission.
@router.post("/runs/{run_id}/bulk-replace")
async def bulk_find_replace(
def bulk_find_replace(
run_id: str,
payload: BulkFindReplaceRequest,
current_user: User = Depends(get_current_user),