Spaces:
Sleeping
Sleeping
Commit ·
42f0165
1
Parent(s): 73ec9df
feat: support workload-specific model configuration, trust role description grounding, and remove emojis
Browse files- README.md +0 -1
- src/psview_agent/api/routes/agents.py +70 -2
- src/psview_agent/api/routes/conversations.py +57 -3
- src/psview_agent/core/config.py +15 -0
- src/psview_agent/core/middleware.py +40 -0
- src/psview_agent/integrations/models/gateway.py +106 -53
- src/psview_agent/integrations/models/structured_output.py +1 -1
- src/psview_agent/prompts/action_planning.py +7 -5
- src/psview_agent/prompts/response_evaluation.py +3 -1
- src/psview_agent/prompts/response_generation.py +8 -7
- src/psview_agent/prompts/response_revision.py +4 -4
- tests/unit/test_conversations_persistence.py +72 -0
- tests/unit/test_model_gateway_routing.py +2 -2
- tests/unit/test_support_modules.py +22 -0
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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
"
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
)
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 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 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 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
|
| 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 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
| 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 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
|
|
|
| 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
|
| 36 |
-
"with the revised message. Every factual company claim
|
| 37 |
-
"from decision.company_fact_ids_to_use
|
| 38 |
-
"
|
| 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
|
| 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.
|
| 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"
|