dp-zp-agent/tools/answers/model_client.py

153 lines
7.1 KiB
Python

"""Provider adapter for verified delivery; guards stay independent of the model vendor."""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
import threading
import time
import hashlib
from datetime import datetime, timezone
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def model_configuration() -> dict:
provider = os.getenv("ANSWER_PROVIDER", "openai_compatible").strip()
model = os.getenv("ANSWER_MODEL", "").strip()
if provider == "anthropic":
key = (os.getenv("ANSWER_API_KEY") or os.getenv("ANTHROPIC_API_KEY", "")).strip()
endpoint = os.getenv("ANSWER_LLM_URL", "https://api.anthropic.com/v1/messages").strip()
elif provider == "openai_compatible":
legacy = os.getenv("OPENWEBUI_API_KEY", "").strip()
key = os.getenv("ANSWER_API_KEY", "").strip() or legacy
endpoint = os.getenv("ANSWER_LLM_URL", "https://ui.tukekemt.xyz/api/chat/completions" if legacy else "").strip()
model = model or ("model120-fast" if legacy else "")
else:
raise RuntimeError("Unsupported answer provider")
parsed = urllib.parse.urlsplit(endpoint)
if (not key or not model or any(c in key for c in "\r\n") or parsed.scheme != "https"
or not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.fragment or parsed.query or any(c.isspace() for c in endpoint)):
raise RuntimeError("Answer model is not configured")
return {'provider':provider, 'model':model, 'endpoint':endpoint, 'key':key}
_status_lock = threading.Lock()
_status = {}
HEALTH_MAX_AGE_SECONDS = 900
def configuration_key(config: dict) -> str:
return hashlib.sha256(json.dumps(config,sort_keys=True).encode()).hexdigest()
def generation_status() -> dict:
try:
config = model_configuration()
except (RuntimeError,ValueError):
return {'ready':False,'status':'unavailable','reason':'model_not_configured','configured':False}
public={'configured':True,'provider':config['provider'],'model':config['model']}
with _status_lock:
status = dict(_status)
if status.get('configuration') != configuration_key(config) or time.time()-status.get('checked_at',0)>HEALTH_MAX_AGE_SECONDS:
return {**public,'ready':False,'status':'unknown','reason':'model_availability_not_verified'}
return {**public,'ready':status['success'],'status':'ready' if status['success'] else 'unavailable',
'reason':None if status['success'] else 'model_probe_failed',
'checked_at_utc':datetime.fromtimestamp(status['checked_at'],timezone.utc).isoformat()}
def complete_with_configured_model(messages: list[dict], *, max_tokens: int | None = None, output_schema: dict | None = None) -> dict:
config = model_configuration()
try:
options={'max_tokens':max_tokens}
# Compatible endpoints do not necessarily implement constrained decoding.
# Enable only for a provider/model whose capability was configured.
if output_schema is not None and os.getenv('ANSWER_STRUCTURED_OUTPUT','prompt')=='json_schema':
options['output_schema']=strict_output_schema(output_schema)
result = _complete_request(messages,config,**options)
except Exception:
with _status_lock:
_status.update(configuration=configuration_key(config), checked_at=time.time(),success=False)
raise
with _status_lock:
_status.update(configuration=configuration_key(config),checked_at=time.time(),success=True)
return result
def probe_generation() -> dict:
try:
complete_with_configured_model([{'role':'user','content':'Reply only with OK.'}],max_tokens=32)
except Exception:
pass # The caller gets only sanitized configuration/availability, never raw exceptions.
return generation_status()
def _complete_request(messages: list[dict], config: dict, *, max_tokens: int | None = None, output_schema: dict | None = None) -> dict:
if max_tokens is not None and (type(max_tokens) is not int or not 1 <= max_tokens <= 16384):
raise ValueError("Invalid model output budget")
provider,model,key,endpoint = (config[k] for k in ('provider','model','key','endpoint'))
if provider == "anthropic":
system = "\n\n".join(row["content"] for row in messages if row["role"] == "system")
body = {
"model": model,
"messages": [row for row in messages if row["role"] != "system"],
"max_tokens": max_tokens or int(os.getenv("ANSWER_MAX_TOKENS", "4096")),
"temperature": 0,
}
if system:
body["system"] = system
if output_schema is not None:
body['output_config']={'format':{'type':'json_schema','schema':output_schema}}
headers = {"x-api-key": key, "anthropic-version": "2023-06-01"}
elif provider == "openai_compatible":
body = {"model": model, "messages": messages, "temperature": 0, "stream": False}
if max_tokens is not None:
body["max_tokens"] = max_tokens
if output_schema is not None:
body['response_format']={'type':'json_schema','json_schema':{'name':'question_plan','strict':True,'schema':output_schema}}
headers = {"Authorization": "Bearer " + key}
else:
raise RuntimeError("Unsupported answer provider")
request = urllib.request.Request(
endpoint,
data=json.dumps(body, ensure_ascii=False).encode(),
headers={"Content-Type": "application/json", **headers},
)
with urllib.request.build_opener(NoRedirect()).open(request, timeout=120) as response:
data = response.read(2 * 1024 * 1024 + 1)
if len(data) > 2 * 1024 * 1024:
raise RuntimeError("Answer model output is too large")
value = json.loads(data)
if provider == "anthropic":
if value.get("stop_reason") != "end_turn":
raise RuntimeError("Incomplete answer model output")
text = "".join(
block["text"] for block in value.get("content", []) if block.get("type") == "text"
)
if not text:
raise RuntimeError("Missing answer model output")
return {"choices": [{"message": {"content": text}}]}
if not isinstance(value,dict) or not value.get('choices'):
raise RuntimeError('Invalid answer model response')
choice=value['choices'][0]
if choice.get('finish_reason') not in (None,'stop') or not choice.get('message',{}).get('content'):
raise RuntimeError('Incomplete answer model output')
return value
def strict_output_schema(value):
"""Provider schema subset; validate the result against full Pydantic constraints."""
if isinstance(value,list):return [strict_output_schema(v) for v in value]
if not isinstance(value,dict):return value
result={k:strict_output_schema(v) for k,v in value.items() if k not in {'default','minimum','maximum','minLength','maxLength','minItems','maxItems','format'}}
if result.get('type')=='object':
result['additionalProperties']=False
result['required']=list(result.get('properties',{}))
return result