shivamsrng16 commited on
Commit
42f0165
·
1 Parent(s): 73ec9df

feat: support workload-specific model configuration, trust role description grounding, and remove emojis

Browse files
README.md CHANGED
@@ -1,6 +1,5 @@
1
  ---
2
  title: Hirewire Backend
3
- emoji: ⚡
4
  colorFrom: pink
5
  colorTo: purple
6
  sdk: docker
 
1
  ---
2
  title: Hirewire Backend
 
3
  colorFrom: pink
4
  colorTo: purple
5
  sdk: docker
src/psview_agent/api/routes/agents.py CHANGED
@@ -6,7 +6,8 @@ import logging
6
  import datetime
7
  from typing import Annotated
8
 
9
- from fastapi import APIRouter, Depends, Header, Request
 
10
 
11
  from psview_agent.api.dependencies import get_agent_configuration_service
12
  from psview_agent.domain.api import ConfigureAgentRequest, ConfigureAgentResponse
@@ -18,6 +19,55 @@ LOGGER = logging.getLogger("psview_agent.api.routes.agents")
18
  router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  @router.post("/configure", response_model=ConfigureAgentResponse)
22
  async def configure_agent(
23
  request: ConfigureAgentRequest,
@@ -48,13 +98,31 @@ async def configure_agent(
48
  if resolved_location:
49
  location = resolved_location
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  await db.interactions.insert_one({
52
  "user_id": x_user_id,
53
  "location": location,
54
  "timestamp": datetime.datetime.now(datetime.timezone.utc),
55
  "action": "configure",
56
  "company_context": request.company_context.model_dump(mode="json"),
57
- "configuration": configuration.model_dump(mode="json")
 
 
58
  })
59
  except Exception as err:
60
  LOGGER.warning(f"Failed to log configure interaction: {err}")
 
6
  import datetime
7
  from typing import Annotated
8
 
9
+ from fastapi import APIRouter, Depends, Header, Request, Body
10
+ from openai import AsyncOpenAI
11
 
12
  from psview_agent.api.dependencies import get_agent_configuration_service
13
  from psview_agent.domain.api import ConfigureAgentRequest, ConfigureAgentResponse
 
19
  router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
20
 
21
 
22
+ @router.post("/test-connection")
23
+ async def test_connection(
24
+ request: Request,
25
+ provider: str = Body(..., embed=True),
26
+ model_name: str = Body(..., embed=True),
27
+ api_key: str = Body(..., embed=True),
28
+ ) -> dict[str, object]:
29
+ """Test connection to an AI provider with the given api key and model."""
30
+ provider_str = provider.lower()
31
+ base_url = ""
32
+ if provider_str == "openai":
33
+ base_url = "https://api.openai.com/v1"
34
+ elif provider_str == "gemini":
35
+ base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
36
+ elif provider_str == "openrouter":
37
+ base_url = "https://openrouter.ai/api/v1"
38
+ elif provider_str == "nvidia":
39
+ base_url = "https://integrate.api.nvidia.com/v1"
40
+ else:
41
+ return {"status": "error", "message": f"Unsupported provider: {provider}"}
42
+
43
+ headers = {}
44
+ if provider_str == "openrouter":
45
+ settings = request.app.state.settings
46
+ if settings.openrouter.site_url is not None:
47
+ headers["HTTP-Referer"] = str(settings.openrouter.site_url)
48
+ if settings.openrouter.app_name:
49
+ headers["X-OpenRouter-Title"] = settings.openrouter.app_name
50
+
51
+ try:
52
+ client = AsyncOpenAI(
53
+ api_key=api_key,
54
+ base_url=base_url,
55
+ timeout=10.0,
56
+ max_retries=0,
57
+ default_headers=headers,
58
+ )
59
+ # Make a tiny chat completions call to verify
60
+ await client.chat.completions.create(
61
+ model=model_name,
62
+ messages=[{"role": "user", "content": "Ping"}],
63
+ max_tokens=2,
64
+ )
65
+ await client.close()
66
+ return {"status": "success", "message": "Connection verified successfully!"}
67
+ except Exception as e:
68
+ return {"status": "error", "message": str(e)}
69
+
70
+
71
  @router.post("/configure", response_model=ConfigureAgentResponse)
72
  async def configure_agent(
73
  request: ConfigureAgentRequest,
 
98
  if resolved_location:
99
  location = resolved_location
100
 
101
+ model_provider = None
102
+ model_name = None
103
+ try:
104
+ from psview_agent.core.config import model_override_var
105
+ override = model_override_var.get()
106
+ if override:
107
+ model_provider = override.provider.value
108
+ model_name = override.model_name
109
+ else:
110
+ settings = fastapi_request.app.state.settings
111
+ if settings:
112
+ model_provider = settings.model.provider.value
113
+ model_name = settings.model.model_name
114
+ except Exception:
115
+ pass
116
+
117
  await db.interactions.insert_one({
118
  "user_id": x_user_id,
119
  "location": location,
120
  "timestamp": datetime.datetime.now(datetime.timezone.utc),
121
  "action": "configure",
122
  "company_context": request.company_context.model_dump(mode="json"),
123
+ "configuration": configuration.model_dump(mode="json"),
124
+ "model_provider": model_provider,
125
+ "model_name": model_name,
126
  })
127
  except Exception as err:
128
  LOGGER.warning(f"Failed to log configure interaction: {err}")
src/psview_agent/api/routes/conversations.py CHANGED
@@ -149,6 +149,22 @@ async def log_error_interaction(
149
  if resolved_location:
150
  location = resolved_location
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  await db.interactions.insert_one({
153
  "user_id": x_user_id,
154
  "location": location,
@@ -161,7 +177,9 @@ async def log_error_interaction(
161
  "error_code": error_code,
162
  "message": friendly_msg,
163
  "error_type": exc.__class__.__name__,
164
- "details": [str(d) for d in getattr(exc, "details", [])]
 
 
165
  })
166
  except Exception as err:
167
  LOGGER.warning(f"Failed to log error interaction: {err}")
@@ -287,6 +305,22 @@ async def start_conversation(
287
  if resolved_location:
288
  location = resolved_location
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  await db.interactions.insert_one({
291
  "user_id": x_user_id,
292
  "location": location,
@@ -297,7 +331,9 @@ async def start_conversation(
297
  "target_role_description": request.target_role_description,
298
  "candidate": request.candidate.model_dump(mode="json"),
299
  "initial_response": session.messages[-1].content if session.messages else None,
300
- "initial_decision_trace": trace.model_dump(mode="json") if trace else None
 
 
301
  })
302
  except Exception as err:
303
  LOGGER.warning(f"Failed to log start interaction: {err}")
@@ -351,6 +387,22 @@ async def conversation_turn(
351
  if resolved_location:
352
  location = resolved_location
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  await db.interactions.insert_one({
355
  "user_id": x_user_id,
356
  "location": location,
@@ -361,7 +413,9 @@ async def conversation_turn(
361
  "target_role": request.session.target_role,
362
  "candidate_reply": request.candidate_reply,
363
  "agent_response": response.agent_message.content if response.agent_message else None,
364
- "decision_trace": response.decision_trace.model_dump(mode="json") if response.decision_trace else None
 
 
365
  })
366
  except Exception as err:
367
  LOGGER.warning(f"Failed to log turn interaction: {err}")
 
149
  if resolved_location:
150
  location = resolved_location
151
 
152
+ model_provider = None
153
+ model_name = None
154
+ try:
155
+ from psview_agent.core.config import model_override_var
156
+ override = model_override_var.get()
157
+ if override:
158
+ model_provider = override.provider.value
159
+ model_name = override.model_name
160
+ else:
161
+ settings = fastapi_request.app.state.settings
162
+ if settings:
163
+ model_provider = settings.model.provider.value
164
+ model_name = settings.model.model_name
165
+ except Exception:
166
+ pass
167
+
168
  await db.interactions.insert_one({
169
  "user_id": x_user_id,
170
  "location": location,
 
177
  "error_code": error_code,
178
  "message": friendly_msg,
179
  "error_type": exc.__class__.__name__,
180
+ "details": [str(d) for d in getattr(exc, "details", [])],
181
+ "model_provider": model_provider,
182
+ "model_name": model_name,
183
  })
184
  except Exception as err:
185
  LOGGER.warning(f"Failed to log error interaction: {err}")
 
305
  if resolved_location:
306
  location = resolved_location
307
 
308
+ model_provider = None
309
+ model_name = None
310
+ try:
311
+ from psview_agent.core.config import model_override_var
312
+ override = model_override_var.get()
313
+ if override:
314
+ model_provider = override.provider.value
315
+ model_name = override.model_name
316
+ else:
317
+ settings = fastapi_request.app.state.settings
318
+ if settings:
319
+ model_provider = settings.model.provider.value
320
+ model_name = settings.model.model_name
321
+ except Exception:
322
+ pass
323
+
324
  await db.interactions.insert_one({
325
  "user_id": x_user_id,
326
  "location": location,
 
331
  "target_role_description": request.target_role_description,
332
  "candidate": request.candidate.model_dump(mode="json"),
333
  "initial_response": session.messages[-1].content if session.messages else None,
334
+ "initial_decision_trace": trace.model_dump(mode="json") if trace else None,
335
+ "model_provider": model_provider,
336
+ "model_name": model_name,
337
  })
338
  except Exception as err:
339
  LOGGER.warning(f"Failed to log start interaction: {err}")
 
387
  if resolved_location:
388
  location = resolved_location
389
 
390
+ model_provider = None
391
+ model_name = None
392
+ try:
393
+ from psview_agent.core.config import model_override_var
394
+ override = model_override_var.get()
395
+ if override:
396
+ model_provider = override.provider.value
397
+ model_name = override.model_name
398
+ else:
399
+ settings = fastapi_request.app.state.settings
400
+ if settings:
401
+ model_provider = settings.model.provider.value
402
+ model_name = settings.model.model_name
403
+ except Exception:
404
+ pass
405
+
406
  await db.interactions.insert_one({
407
  "user_id": x_user_id,
408
  "location": location,
 
413
  "target_role": request.session.target_role,
414
  "candidate_reply": request.candidate_reply,
415
  "agent_response": response.agent_message.content if response.agent_message else None,
416
+ "decision_trace": response.decision_trace.model_dump(mode="json") if response.decision_trace else None,
417
+ "model_provider": model_provider,
418
+ "model_name": model_name,
419
  })
420
  except Exception as err:
421
  LOGGER.warning(f"Failed to log turn interaction: {err}")
src/psview_agent/core/config.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  from enum import StrEnum
6
  from functools import lru_cache
 
7
 
8
  from pydantic import AnyHttpUrl, Field, SecretStr, field_validator, model_validator
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -20,6 +21,8 @@ class AppEnvironment(StrEnum):
20
  class ModelProvider(StrEnum):
21
  OPENROUTER = "openrouter"
22
  NVIDIA = "nvidia"
 
 
23
 
24
 
25
  class StructuredOutputMode(StrEnum):
@@ -29,6 +32,18 @@ class StructuredOutputMode(StrEnum):
29
  PROMPT_JSON = "prompt_json"
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  class AppSettings(StrictModel):
33
  name: str = Field(min_length=1, max_length=120)
34
  env: AppEnvironment
 
4
 
5
  from enum import StrEnum
6
  from functools import lru_cache
7
+ from contextvars import ContextVar
8
 
9
  from pydantic import AnyHttpUrl, Field, SecretStr, field_validator, model_validator
10
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
21
  class ModelProvider(StrEnum):
22
  OPENROUTER = "openrouter"
23
  NVIDIA = "nvidia"
24
+ GEMINI = "gemini"
25
+ OPENAI = "openai"
26
 
27
 
28
  class StructuredOutputMode(StrEnum):
 
32
  PROMPT_JSON = "prompt_json"
33
 
34
 
35
+ class ModelOverride(StrictModel):
36
+ provider: ModelProvider
37
+ api_key: str
38
+ model_name: str
39
+ general_chat_model_name: str | None = None
40
+ structured_json_model_name: str | None = None
41
+ coding_backend_model_name: str | None = None
42
+
43
+
44
+ model_override_var: ContextVar[ModelOverride | None] = ContextVar("model_override", default=None)
45
+
46
+
47
  class AppSettings(StrictModel):
48
  name: str = Field(min_length=1, max_length=120)
49
  env: AppEnvironment
src/psview_agent/core/middleware.py CHANGED
@@ -58,6 +58,45 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
58
  },
59
  )
60
  return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
  class MaxRequestBodySizeMiddleware:
@@ -164,6 +203,7 @@ class DynamicCORSMiddleware(BaseHTTPMiddleware):
164
 
165
  def install_http_middleware(app: FastAPI, *, default_max_request_body_bytes: int) -> None:
166
  """Install core HTTP middleware."""
 
167
  app.add_middleware(RequestContextMiddleware)
168
  app.add_middleware(DynamicCORSMiddleware)
169
  app.add_middleware(
 
58
  },
59
  )
60
  return response
61
+
62
+
63
+ class ModelOverrideMiddleware(BaseHTTPMiddleware):
64
+ """Intercept model configuration override headers and set the ContextVar."""
65
+
66
+ async def dispatch(
67
+ self,
68
+ request: Request,
69
+ call_next: RequestResponseEndpoint,
70
+ ) -> Response:
71
+ provider = request.headers.get("X-Model-Provider")
72
+ model_name = request.headers.get("X-Model-Name")
73
+ api_key = request.headers.get("X-Model-Api-Key")
74
+ general_chat_model_name = request.headers.get("X-Model-General-Chat-Name")
75
+ structured_json_model_name = request.headers.get("X-Model-Structured-Json-Name")
76
+ coding_backend_model_name = request.headers.get("X-Model-Coding-Backend-Name")
77
+
78
+ token = None
79
+ if provider and model_name and api_key:
80
+ from psview_agent.core.config import ModelOverride, ModelProvider, model_override_var
81
+ try:
82
+ override = ModelOverride(
83
+ provider=ModelProvider(provider.lower()),
84
+ api_key=api_key,
85
+ model_name=model_name,
86
+ general_chat_model_name=general_chat_model_name,
87
+ structured_json_model_name=structured_json_model_name,
88
+ coding_backend_model_name=coding_backend_model_name,
89
+ )
90
+ token = model_override_var.set(override)
91
+ except Exception as e:
92
+ LOGGER.warning(f"Failed to set model override from headers: {e}")
93
+
94
+ try:
95
+ return await call_next(request)
96
+ finally:
97
+ if token is not None:
98
+ from psview_agent.core.config import model_override_var
99
+ model_override_var.reset(token)
100
 
101
 
102
  class MaxRequestBodySizeMiddleware:
 
203
 
204
  def install_http_middleware(app: FastAPI, *, default_max_request_body_bytes: int) -> None:
205
  """Install core HTTP middleware."""
206
+ app.add_middleware(ModelOverrideMiddleware)
207
  app.add_middleware(RequestContextMiddleware)
208
  app.add_middleware(DynamicCORSMiddleware)
209
  app.add_middleware(
src/psview_agent/integrations/models/gateway.py CHANGED
@@ -170,7 +170,7 @@ class OpenAICompatibleModelGateway(ModelGateway):
170
  schema_name="agent_decision",
171
  system_prompt=system_prompt,
172
  user_prompt=user_prompt,
173
- workload=ModelWorkload.STRUCTURED_JSON,
174
  )
175
 
176
  async def generate_candidate_response(
@@ -299,7 +299,11 @@ class OpenAICompatibleModelGateway(ModelGateway):
299
  except Exception as exc:
300
  from instructor.core import InstructorRetryException
301
  from pydantic import ValidationError as PydanticValidationError
302
- if isinstance(exc, (InstructorRetryException, PydanticValidationError)):
 
 
 
 
303
  mapped = ModelInvalidOutputError(f"instructor structured output validation failed: {exc}")
304
  else:
305
  mapped = map_openai_error(exc)
@@ -321,6 +325,15 @@ class OpenAICompatibleModelGateway(ModelGateway):
321
  )
322
 
323
  def _resolve_model_name(self, workload: ModelWorkload) -> str:
 
 
 
 
 
 
 
 
 
324
  if workload is ModelWorkload.GENERAL_CHAT:
325
  return self._settings.model.general_chat_model_name or self._settings.model.model_name
326
  if workload is ModelWorkload.CODING_BACKEND:
@@ -329,8 +342,11 @@ class OpenAICompatibleModelGateway(ModelGateway):
329
 
330
  def _mode_attempts(self, model_name: str) -> list[StructuredOutputMode]:
331
  cached_mode = self._cached_modes.get(model_name)
 
 
 
332
  seq = mode_sequence(
333
- self._settings.model.provider,
334
  self._settings.model.structured_output_mode,
335
  )
336
  if cached_mode is not None and cached_mode in seq:
@@ -359,62 +375,99 @@ class OpenAICompatibleModelGateway(ModelGateway):
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()
 
170
  schema_name="agent_decision",
171
  system_prompt=system_prompt,
172
  user_prompt=user_prompt,
173
+ workload=ModelWorkload.CODING_BACKEND,
174
  )
175
 
176
  async def generate_candidate_response(
 
299
  except Exception as exc:
300
  from instructor.core import InstructorRetryException
301
  from pydantic import ValidationError as PydanticValidationError
302
+ import openai
303
+
304
+ if isinstance(exc, InstructorRetryException) and isinstance(exc.__cause__, openai.APIError):
305
+ mapped = map_openai_error(exc.__cause__)
306
+ elif isinstance(exc, (InstructorRetryException, PydanticValidationError)):
307
  mapped = ModelInvalidOutputError(f"instructor structured output validation failed: {exc}")
308
  else:
309
  mapped = map_openai_error(exc)
 
325
  )
326
 
327
  def _resolve_model_name(self, workload: ModelWorkload) -> str:
328
+ from psview_agent.core.config import model_override_var
329
+ override = model_override_var.get()
330
+ if override is not None:
331
+ if workload is ModelWorkload.GENERAL_CHAT:
332
+ return override.general_chat_model_name or override.model_name
333
+ if workload is ModelWorkload.CODING_BACKEND:
334
+ return override.coding_backend_model_name or override.model_name
335
+ return override.structured_json_model_name or override.model_name
336
+
337
  if workload is ModelWorkload.GENERAL_CHAT:
338
  return self._settings.model.general_chat_model_name or self._settings.model.model_name
339
  if workload is ModelWorkload.CODING_BACKEND:
 
342
 
343
  def _mode_attempts(self, model_name: str) -> list[StructuredOutputMode]:
344
  cached_mode = self._cached_modes.get(model_name)
345
+ from psview_agent.core.config import model_override_var
346
+ override = model_override_var.get()
347
+ provider = override.provider if override else self._settings.model.provider
348
  seq = mode_sequence(
349
+ provider,
350
  self._settings.model.structured_output_mode,
351
  )
352
  if cached_mode is not None and cached_mode in seq:
 
375
  if mode in {StructuredOutputMode.JSON_OBJECT, StructuredOutputMode.PROMPT_JSON}:
376
  prompt_suffix = "\n" + prompt_json_instructions(output_model)
377
 
378
+ from psview_agent.core.config import model_override_var
379
+ override = model_override_var.get()
380
 
381
+ client_to_use = self._client
382
+ temp_client = None
383
+
384
+ if override is not None:
385
+ base_url = ""
386
+ if override.provider == "openai":
387
+ base_url = "https://api.openai.com/v1"
388
+ elif override.provider == "gemini":
389
+ base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
390
+ elif override.provider == "openrouter":
391
+ base_url = "https://openrouter.ai/api/v1"
392
+ elif override.provider == "nvidia":
393
+ base_url = "https://integrate.api.nvidia.com/v1"
394
+
395
+ headers = {}
396
+ if override.provider == "openrouter":
397
+ if self._settings.openrouter.site_url is not None:
398
+ headers["HTTP-Referer"] = str(self._settings.openrouter.site_url)
399
+ if self._settings.openrouter.app_name:
400
+ headers["X-OpenRouter-Title"] = self._settings.openrouter.app_name
401
+
402
+ temp_client = AsyncOpenAI(
403
+ api_key=override.api_key,
404
+ base_url=base_url,
405
+ timeout=self._settings.model.timeout_seconds,
406
+ max_retries=self._settings.model.max_retries,
407
+ default_headers=headers,
408
  )
409
+ client_to_use = temp_client
410
+
411
+ try:
412
+ instructor_client = instructor.from_openai(client_to_use, mode=mode_map[mode])
 
 
 
413
 
414
+ try:
415
+ async with self._semaphore:
416
+ parsed = await instructor_client.chat.completions.create(
417
+ model=model_name,
418
+ messages=[
419
+ {"role": "system", "content": system_prompt + prompt_suffix},
420
+ {"role": "user", "content": user_prompt},
421
+ ],
422
+ response_model=output_model,
423
+ temperature=self._settings.model.temperature,
424
+ max_tokens=self._settings.model.max_output_tokens,
425
+ max_retries=self._settings.model.repair_attempts,
426
+ extra_body=self._settings.model.extra_body,
427
+ )
428
+
429
+ LOGGER.info(
430
+ "Instructor model completion succeeded",
431
+ extra={
432
+ "provider": override.provider.value if override else self._settings.model.provider.value,
433
+ "model_name": model_name,
434
+ "structured_output_mode": mode.value,
435
+ },
436
+ )
437
+ self._cached_modes[model_name] = mode
438
+ return sanitize_model_strings(parsed)
439
+ except Exception as exc:
440
+ # Extract raw response text if validation failed under Instructor
441
+ raw_content = None
442
+ if hasattr(exc, "last_completion") and exc.last_completion:
443
+ raw_content = getattr(exc.last_completion.choices[0].message, "content", None)
444
+
445
+ if raw_content:
446
+ LOGGER.warning(
447
+ "Instructor validation failed for model %s; attempting custom recursive repair. error: %s. content: %s",
448
+ output_model.__name__,
449
+ str(exc),
450
+ raw_content,
451
+ )
452
+ repaired = self._attempt_repair(
453
+ content=raw_content,
454
+ errors=str(exc),
455
+ output_model=output_model,
456
+ )
457
+ if repaired is not None:
458
+ LOGGER.info("successfully repaired model %s via fallback repair", output_model.__name__)
459
+ self._cached_modes[model_name] = mode
460
+ return repaired
461
+
462
+ LOGGER.error(
463
+ "Instructor execution failed for model %s. error: %s",
464
  output_model.__name__,
465
  str(exc),
 
 
 
 
 
 
466
  )
467
+ raise
468
+ finally:
469
+ if temp_client is not None:
470
+ await temp_client.close()
 
 
 
 
 
 
 
471
 
472
  def _clean_json_text(self, content: str) -> str:
473
  content = content.strip()
src/psview_agent/integrations/models/structured_output.py CHANGED
@@ -12,7 +12,7 @@ def mode_sequence(
12
  """Return the allowed fallback sequence."""
13
  if preferred is not StructuredOutputMode.AUTO:
14
  return [preferred]
15
- if provider is ModelProvider.OPENROUTER:
16
  return [
17
  StructuredOutputMode.JSON_SCHEMA,
18
  StructuredOutputMode.JSON_OBJECT,
 
12
  """Return the allowed fallback sequence."""
13
  if preferred is not StructuredOutputMode.AUTO:
14
  return [preferred]
15
+ if provider in (ModelProvider.OPENROUTER, ModelProvider.OPENAI):
16
  return [
17
  StructuredOutputMode.JSON_SCHEMA,
18
  StructuredOutputMode.JSON_OBJECT,
src/psview_agent/prompts/action_planning.py CHANGED
@@ -28,11 +28,13 @@ def build_action_planning_prompts(
28
  system_prompt = (
29
  "Plan the recruiting agent's next objective and action.\n"
30
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
31
- "Select only evidence IDs from the retrieved evidence. "
32
- "Identify missing information when direct support is absent. The rationale_summary must "
33
- "be specific to the candidate's latest reply and must name the selected evidence IDs or "
34
- "the missing information that blocks a stronger answer. Avoid generic wording such as "
35
- "'keep it grounded' or unsupported fit language."
 
 
36
  )
37
  user_prompt = untrusted_json_block(
38
  {
 
28
  system_prompt = (
29
  "Plan the recruiting agent's next objective and action.\n"
30
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
31
+ "Select only evidence IDs from the retrieved evidence. Note that the target role and its description "
32
+ "(in role_context) are fully trusted sources of truth for the role's details, requirements, qualifications, "
33
+ "and responsibilities. Do NOT treat role details or qualifications explicitly stated in the role description "
34
+ "as missing information. Identify missing information only when a topic is not supported by either the "
35
+ "retrieved evidence or the role description. The rationale_summary must be specific to the candidate's latest reply "
36
+ "and must name the selected evidence IDs, role context details used, or the missing information that blocks a stronger answer. "
37
+ "Avoid generic wording such as 'keep it grounded' or unsupported fit language."
38
  )
39
  user_prompt = untrusted_json_block(
40
  {
src/psview_agent/prompts/response_evaluation.py CHANGED
@@ -30,7 +30,9 @@ def build_response_evaluation_prompts(
30
  "Evaluate the candidate-facing response independently.\n"
31
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
32
  "Check persona consistency, grounding, relevance, action alignment, "
33
- "naturalness, repetition, and policy issues."
 
 
34
  )
35
  user_prompt = untrusted_json_block(
36
  {
 
30
  "Evaluate the candidate-facing response independently.\n"
31
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
32
  "Check persona consistency, grounding, relevance, action alignment, "
33
+ "naturalness, repetition, and policy issues. The target role and description in role_context "
34
+ "are fully trusted. Any claims directly supported by the role description are considered grounded "
35
+ "and must NOT be flagged as unsupported claims, even if they don't have associated company evidence fact IDs."
36
  )
37
  user_prompt = untrusted_json_block(
38
  {
src/psview_agent/prompts/response_generation.py CHANGED
@@ -27,13 +27,14 @@ def build_response_generation_prompts(
27
  system_prompt = (
28
  "Generate a grounded candidate-facing response.\n"
29
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
30
- "Follow the selected action, stay in persona, use only selected "
31
- "evidence, remain concise, and ask at most one question. Return "
32
- "supported_claims for each factual company claim you make. Each "
33
- "supported claim must quote or closely paraphrase one claim from the "
34
- "message and cite only exact evidence IDs from decision.company_fact_ids_to_use. "
35
- "If a sentence is purely courtesy, transition, or AI disclosure without a company "
36
- "fact, do not include it in supported_claims."
 
37
  )
38
  user_prompt = untrusted_json_block(
39
  {
 
27
  system_prompt = (
28
  "Generate a grounded candidate-facing response.\n"
29
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
30
+ "Follow the selected action, stay in persona, use only selected evidence and the trusted role description "
31
+ "(in role_context), remain concise, and ask at most one question. The role title and description are "
32
+ "fully trusted sources of truth for role requirements and details. You can freely state any requirements, qualifications, "
33
+ "or details explicitly mentioned in the role description. Return supported_claims only for factual company claims "
34
+ "made that are based on company evidence facts (citing exact evidence IDs from decision.company_fact_ids_to_use). "
35
+ "Do NOT create supported_claims or cite evidence IDs for claims that are directly supported by the role description itself, "
36
+ "as they are trusted implicitly. If a sentence is purely courtesy, transition, AI disclosure, or directly from the "
37
+ "trusted role description, do not include it in supported_claims."
38
  )
39
  user_prompt = untrusted_json_block(
40
  {
src/psview_agent/prompts/response_revision.py CHANGED
@@ -32,10 +32,10 @@ def build_response_revision_prompts(
32
  "Revise the candidate-facing response once.\n"
33
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
34
  "Fix only the identified failures, preserve valid content, keep the "
35
- "selected action, and use only supported evidence. Keep supported_claims aligned "
36
- "with the revised message. Every factual company claim must cite exact evidence IDs "
37
- "from decision.company_fact_ids_to_use, and unsupported or uncited claims must be "
38
- "removed instead of guessed."
39
  )
40
  user_prompt = untrusted_json_block(
41
  {
 
32
  "Revise the candidate-facing response once.\n"
33
  f"{PROMPT_SECURITY_INSTRUCTION}\n"
34
  "Fix only the identified failures, preserve valid content, keep the "
35
+ "selected action, and use only supported evidence and the trusted role description (in role_context). "
36
+ "Keep supported_claims aligned with the revised message. Every factual company claim (based on company evidence) "
37
+ "must cite exact evidence IDs from decision.company_fact_ids_to_use. Claims directly based on the trusted role description "
38
+ "do not need evidence IDs and should not be cited in supported_claims. Remove any guessed or unsupported company claims."
39
  )
40
  user_prompt = untrusted_json_block(
41
  {
tests/unit/test_conversations_persistence.py CHANGED
@@ -239,3 +239,75 @@ async def test_api_routes_error_persistence_logging(client: AsyncClient, app, mo
239
  assert call_args["error_code"] == "turn_limit_reached"
240
  assert "limit" in call_args["message"].lower()
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  assert call_args["error_code"] == "turn_limit_reached"
240
  assert "limit" in call_args["message"].lower()
241
 
242
+
243
+ @pytest.mark.anyio
244
+ async def test_model_override_middleware(client: AsyncClient, app) -> None:
245
+ from psview_agent.core.config import model_override_var
246
+
247
+ # Add a temporary test endpoint to verify the contextvar
248
+ @app.get("/api/test-context-override")
249
+ async def test_context_override():
250
+ override = model_override_var.get()
251
+ if override:
252
+ return {
253
+ "provider": override.provider.value,
254
+ "model_name": override.model_name,
255
+ "api_key": override.api_key
256
+ }
257
+ return {"override": None}
258
+
259
+ # Send request with headers
260
+ headers = {
261
+ "X-Model-Provider": "gemini",
262
+ "X-Model-Name": "gemini-2.5-flash",
263
+ "X-Model-Api-Key": "my-gemini-key"
264
+ }
265
+
266
+ resp = await client.get("/api/test-context-override", headers=headers)
267
+ assert resp.status_code == 200
268
+ assert resp.json() == {
269
+ "provider": "gemini",
270
+ "model_name": "gemini-2.5-flash",
271
+ "api_key": "my-gemini-key"
272
+ }
273
+
274
+ # Send request without headers
275
+ resp_no_headers = await client.get("/api/test-context-override")
276
+ assert resp_no_headers.status_code == 200
277
+ assert resp_no_headers.json() == {"override": None}
278
+
279
+
280
+ @pytest.mark.anyio
281
+ async def test_model_override_middleware_and_test_connection(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
282
+ # Mock AsyncOpenAI call inside test_connection
283
+ mock_create = AsyncMock()
284
+ mock_client_instance = MagicMock()
285
+ mock_client_instance.chat = MagicMock()
286
+ mock_client_instance.chat.completions = MagicMock()
287
+ mock_client_instance.chat.completions.create = mock_create
288
+ mock_client_instance.close = AsyncMock()
289
+
290
+ mock_init = MagicMock(return_value=mock_client_instance)
291
+ monkeypatch.setattr("psview_agent.api.routes.agents.AsyncOpenAI", mock_init)
292
+
293
+ # Test connection request payload
294
+ test_payload = {
295
+ "provider": "openai",
296
+ "model_name": "gpt-4o-test",
297
+ "api_key": "my-custom-key"
298
+ }
299
+
300
+ response = await client.post("/api/v1/agents/test-connection", json=test_payload)
301
+ assert response.status_code == 200
302
+ assert response.json() == {"status": "success", "message": "Connection verified successfully!"}
303
+
304
+ # Ensure it initialized with the correct base url and api key
305
+ mock_init.assert_called_once_with(
306
+ api_key="my-custom-key",
307
+ base_url="https://api.openai.com/v1",
308
+ timeout=10.0,
309
+ max_retries=0,
310
+ default_headers={},
311
+ )
312
+
313
+
tests/unit/test_model_gateway_routing.py CHANGED
@@ -271,7 +271,7 @@ async def test_generate_candidate_response_sanitizes_text_and_derives_ids() -> N
271
 
272
 
273
  @pytest.mark.asyncio
274
- async def test_plan_next_action_uses_structured_json_workload() -> None:
275
  gateway = OpenAICompatibleModelGateway(
276
  client=cast(AsyncOpenAI, object()),
277
  settings=_settings(),
@@ -306,7 +306,7 @@ async def test_plan_next_action_uses_structured_json_workload() -> None:
306
  retrieved_evidence=[],
307
  )
308
 
309
- assert seen_workloads == [ModelWorkload.STRUCTURED_JSON]
310
 
311
 
312
  def test_gateway_repair_recursive_and_padding() -> None:
 
271
 
272
 
273
  @pytest.mark.asyncio
274
+ async def test_plan_next_action_uses_coding_backend_workload() -> None:
275
  gateway = OpenAICompatibleModelGateway(
276
  client=cast(AsyncOpenAI, object()),
277
  settings=_settings(),
 
306
  retrieved_evidence=[],
307
  )
308
 
309
+ assert seen_workloads == [ModelWorkload.CODING_BACKEND]
310
 
311
 
312
  def test_gateway_repair_recursive_and_padding() -> None:
tests/unit/test_support_modules.py CHANGED
@@ -256,3 +256,25 @@ def test_text_helpers_normalize_and_split_content() -> None:
256
  assert sanitize_generated_text("Hi\u00a0there\u2014I\u2019m ready\u2026") == (
257
  "Hi there-I'm ready..."
258
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  assert sanitize_generated_text("Hi\u00a0there\u2014I\u2019m ready\u2026") == (
257
  "Hi there-I'm ready..."
258
  )
259
+
260
+
261
+ def test_model_override_and_modes() -> None:
262
+ from psview_agent.core.config import ModelOverride, ModelProvider, model_override_var
263
+ gemini_modes = mode_sequence(ModelProvider.GEMINI, StructuredOutputMode.AUTO)
264
+ openai_modes = mode_sequence(ModelProvider.OPENAI, StructuredOutputMode.AUTO)
265
+
266
+ assert gemini_modes == [StructuredOutputMode.JSON_OBJECT, StructuredOutputMode.PROMPT_JSON]
267
+ assert openai_modes == [
268
+ StructuredOutputMode.JSON_SCHEMA,
269
+ StructuredOutputMode.JSON_OBJECT,
270
+ StructuredOutputMode.PROMPT_JSON,
271
+ ]
272
+
273
+ override = ModelOverride(
274
+ provider=ModelProvider.GEMINI,
275
+ api_key="override-key",
276
+ model_name="gemini-flash-test",
277
+ )
278
+ assert override.provider == ModelProvider.GEMINI
279
+ assert override.api_key == "override-key"
280
+ assert override.model_name == "gemini-flash-test"