shivamsrng16 commited on
Commit
e9a116c
·
1 Parent(s): eacd017

feat: migrate structured model outputs to Instructor with repair recovery fallback

Browse files
pyproject.toml CHANGED
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
10
  dependencies = [
11
  "fastapi>=0.137.2",
12
  "httpx>=0.28.1",
 
13
  "langgraph>=1.2.6",
14
  "motor>=3.6.0",
15
  "openai>=2.43.0",
 
10
  dependencies = [
11
  "fastapi>=0.137.2",
12
  "httpx>=0.28.1",
13
+ "instructor>=1.15.3",
14
  "langgraph>=1.2.6",
15
  "motor>=3.6.0",
16
  "openai>=2.43.0",
src/psview_agent/integrations/models/gateway.py CHANGED
@@ -297,7 +297,12 @@ class OpenAICompatibleModelGateway(ModelGateway):
297
  model_name=model_name,
298
  )
299
  except Exception as exc:
300
- mapped = map_openai_error(exc)
 
 
 
 
 
301
  is_fallbackable = (
302
  is_unsupported_format_error(str(exc), mode=mode) or
303
  isinstance(mapped, (ModelIncompleteResponseError, ModelInvalidOutputError))
@@ -342,65 +347,74 @@ class OpenAICompatibleModelGateway(ModelGateway):
342
  mode: StructuredOutputMode,
343
  model_name: str,
344
  ) -> TModel:
 
 
 
 
 
 
 
 
345
  prompt_suffix = ""
346
- response_format = build_response_format(mode, schema_name, output_model)
347
  if mode in {StructuredOutputMode.JSON_OBJECT, StructuredOutputMode.PROMPT_JSON}:
348
  prompt_suffix = "\n" + prompt_json_instructions(output_model)
349
- content = await self._call_chat_completion(
350
- model_name=model_name,
351
- system_prompt=system_prompt + prompt_suffix,
352
- user_prompt=user_prompt,
353
- response_format=response_format,
354
- )
355
- parsed = self._parse_content_as_model(content=content, output_model=output_model)
356
- self._cached_modes[model_name] = mode
357
- return sanitize_model_strings(parsed)
358
-
359
- async def _call_chat_completion(
360
- self,
361
- *,
362
- model_name: str,
363
- system_prompt: str,
364
- user_prompt: str,
365
- response_format: dict[str, object] | None,
366
- ) -> str:
367
- async with self._semaphore:
368
- create = cast(_ChatCreateCallable, self._client.chat.completions.create)
369
- payload: dict[str, object] = {
370
- "model": model_name,
371
- "messages": [
372
- {"role": "system", "content": system_prompt},
373
- {"role": "user", "content": user_prompt},
374
- ],
375
- "temperature": self._settings.model.temperature,
376
- "max_tokens": self._settings.model.max_output_tokens,
377
- "extra_body": self._settings.model.extra_body,
378
- }
379
- if response_format is not None:
380
- payload["response_format"] = response_format
381
- response_object = await create(**payload)
382
- response = cast(ChatCompletion, response_object)
383
- request_id_obj = getattr(response, "_request_id", None)
384
- request_id = request_id_obj if isinstance(request_id_obj, str) else None
385
- LOGGER.info(
386
- "model completion succeeded",
387
- extra={
388
- "provider": self._settings.model.provider.value,
389
- "model_name": model_name,
390
- "structured_output_mode": (
391
- self._cached_modes[model_name].value
392
- if model_name in self._cached_modes
393
- else self._settings.model.structured_output_mode.value
394
- ),
395
- "provider_request_id": request_id,
396
- },
397
- )
398
- if not response.choices:
399
- raise ModelIncompleteResponseError("provider returned no choices")
400
- content = response.choices[0].message.content
401
- if content is None or not content.strip():
402
- raise ModelIncompleteResponseError("provider returned empty content")
403
- return content
 
 
404
 
405
  def _clean_json_text(self, content: str) -> str:
406
  content = content.strip()
@@ -410,33 +424,6 @@ class OpenAICompatibleModelGateway(ModelGateway):
410
  return content[first_brace:last_brace + 1].strip()
411
  return content
412
 
413
- def _parse_content_as_model(self, *, content: str, output_model: type[TModel]) -> TModel:
414
- cleaned_content = self._clean_json_text(content)
415
- try:
416
- return output_model.model_validate_json(cleaned_content)
417
- except ValidationError as exc:
418
- LOGGER.warning(
419
- "validation failed for model %s; attempting repair. error: %s. content: %s",
420
- output_model.__name__,
421
- str(exc),
422
- cleaned_content,
423
- )
424
- repaired = self._attempt_repair(
425
- content=cleaned_content,
426
- errors=str(exc),
427
- output_model=output_model,
428
- )
429
- if repaired is None:
430
- LOGGER.error(
431
- "repair failed for model %s. error: %s. content: %s",
432
- output_model.__name__,
433
- str(exc),
434
- cleaned_content,
435
- )
436
- raise ModelInvalidOutputError("structured output validation failed") from exc
437
- LOGGER.info("successfully repaired model %s", output_model.__name__)
438
- return repaired
439
-
440
  def _attempt_repair(
441
  self,
442
  *,
@@ -447,7 +434,8 @@ class OpenAICompatibleModelGateway(ModelGateway):
447
  if self._settings.model.repair_attempts <= 0:
448
  return None
449
  try:
450
- raw = json.loads(content)
 
451
  except json.JSONDecodeError:
452
  return None
453
  if not isinstance(raw, dict):
 
297
  model_name=model_name,
298
  )
299
  except Exception as exc:
300
+ import instructor
301
+ from pydantic import ValidationError as PydanticValidationError
302
+ if isinstance(exc, (instructor.exceptions.InstructorRetryException, PydanticValidationError)):
303
+ mapped = ModelInvalidOutputError(f"instructor structured output validation failed: {exc}")
304
+ else:
305
+ mapped = map_openai_error(exc)
306
  is_fallbackable = (
307
  is_unsupported_format_error(str(exc), mode=mode) or
308
  isinstance(mapped, (ModelIncompleteResponseError, ModelInvalidOutputError))
 
347
  mode: StructuredOutputMode,
348
  model_name: str,
349
  ) -> TModel:
350
+ import instructor
351
+
352
+ mode_map = {
353
+ StructuredOutputMode.JSON_SCHEMA: instructor.Mode.JSON_SCHEMA,
354
+ StructuredOutputMode.JSON_OBJECT: instructor.Mode.JSON,
355
+ StructuredOutputMode.PROMPT_JSON: instructor.Mode.MD_JSON,
356
+ }
357
+
358
  prompt_suffix = ""
 
359
  if mode in {StructuredOutputMode.JSON_OBJECT, StructuredOutputMode.PROMPT_JSON}:
360
  prompt_suffix = "\n" + prompt_json_instructions(output_model)
361
+
362
+ instructor_client = instructor.from_openai(self._client, mode=mode_map[mode])
363
+
364
+ try:
365
+ async with self._semaphore:
366
+ parsed = await instructor_client.chat.completions.create(
367
+ model=model_name,
368
+ messages=[
369
+ {"role": "system", "content": system_prompt + prompt_suffix},
370
+ {"role": "user", "content": user_prompt},
371
+ ],
372
+ response_model=output_model,
373
+ temperature=self._settings.model.temperature,
374
+ max_tokens=self._settings.model.max_output_tokens,
375
+ max_retries=self._settings.model.repair_attempts,
376
+ extra_body=self._settings.model.extra_body,
377
+ )
378
+
379
+ LOGGER.info(
380
+ "Instructor model completion succeeded",
381
+ extra={
382
+ "provider": self._settings.model.provider.value,
383
+ "model_name": model_name,
384
+ "structured_output_mode": mode.value,
385
+ },
386
+ )
387
+ self._cached_modes[model_name] = mode
388
+ return sanitize_model_strings(parsed)
389
+ except Exception as exc:
390
+ # Extract raw response text if validation failed under Instructor
391
+ raw_content = None
392
+ if hasattr(exc, "last_completion") and exc.last_completion:
393
+ raw_content = getattr(exc.last_completion.choices[0].message, "content", None)
394
+
395
+ if raw_content:
396
+ LOGGER.warning(
397
+ "Instructor validation failed for model %s; attempting custom recursive repair. error: %s. content: %s",
398
+ output_model.__name__,
399
+ str(exc),
400
+ raw_content,
401
+ )
402
+ repaired = self._attempt_repair(
403
+ content=raw_content,
404
+ errors=str(exc),
405
+ output_model=output_model,
406
+ )
407
+ if repaired is not None:
408
+ LOGGER.info("successfully repaired model %s via fallback repair", output_model.__name__)
409
+ self._cached_modes[model_name] = mode
410
+ return repaired
411
+
412
+ LOGGER.error(
413
+ "Instructor execution failed for model %s. error: %s",
414
+ output_model.__name__,
415
+ str(exc),
416
+ )
417
+ raise
418
 
419
  def _clean_json_text(self, content: str) -> str:
420
  content = content.strip()
 
424
  return content[first_brace:last_brace + 1].strip()
425
  return content
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def _attempt_repair(
428
  self,
429
  *,
 
434
  if self._settings.model.repair_attempts <= 0:
435
  return None
436
  try:
437
+ cleaned = self._clean_json_text(content)
438
+ raw = json.loads(cleaned)
439
  except json.JSONDecodeError:
440
  return None
441
  if not isinstance(raw, dict):
uv.lock CHANGED
The diff for this file is too large to render. See raw diff