|
| 1 | +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. |
| 2 | + |
| 3 | +"""OpenAI-compatible chat framing; runtime execution and retries stay outside.""" |
| 4 | + |
| 5 | +import json |
| 6 | +import math |
| 7 | + |
| 8 | +from pydantic import ValidationError |
| 9 | + |
| 10 | +from nexent.core.model_service.contracts import AssistantMessage, ModelRequest, TokenUsage, ToolCall |
| 11 | +from nexent.core.model_service.http_contracts import PreparedHttpRequest, RawHttpResponse |
| 12 | +from nexent.core.model_service.parameter_transforms import parameter_transform_references, transform_parameter |
| 13 | +from nexent.core.model_service.profiles.contracts import VersionedReference |
| 14 | +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping |
| 15 | +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings |
| 16 | +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules, validate_saved_parameter_values |
| 17 | +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError |
| 18 | +from nexent.core.model_service.profiles.request_parameters import _set, _tokens, apply_extra_api_parameters |
| 19 | +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile |
| 20 | + |
| 21 | + |
| 22 | +class ModelAdapterError(ValueError): |
| 23 | + """Fixed protocol category without model text, tool arguments or raw body.""" |
| 24 | + |
| 25 | + def __init__(self, category: str, *, response: RawHttpResponse | None = None): |
| 26 | + self.category = category |
| 27 | + self.response = response |
| 28 | + super().__init__(category) |
| 29 | + |
| 30 | + |
| 31 | +def _unique_object(pairs): |
| 32 | + result = {} |
| 33 | + for key, value in pairs: |
| 34 | + if key in result: |
| 35 | + raise ValueError("Duplicate JSON member") |
| 36 | + result[key] = value |
| 37 | + return result |
| 38 | + |
| 39 | + |
| 40 | +def _invalid_constant(value): |
| 41 | + raise ValueError("Invalid JSON constant") |
| 42 | + |
| 43 | + |
| 44 | +def _finite_float(value): |
| 45 | + number = float(value) |
| 46 | + if not math.isfinite(number): |
| 47 | + raise ValueError("Invalid JSON number") |
| 48 | + return number |
| 49 | + |
| 50 | + |
| 51 | +def parse_json(text: str): |
| 52 | + return json.loads( |
| 53 | + text, object_pairs_hook=_unique_object, parse_constant=_invalid_constant, parse_float=_finite_float |
| 54 | + ) |
| 55 | + |
| 56 | + |
| 57 | +def _content_parts(value): |
| 58 | + if value is None: |
| 59 | + return [] |
| 60 | + if isinstance(value, str): |
| 61 | + return [{"type": "text", "text": value}] |
| 62 | + if isinstance(value, list) and all(isinstance(part, dict) and isinstance(part.get("type"), str) for part in value): |
| 63 | + return value |
| 64 | + raise ModelAdapterError("MODEL_RESPONSE_INVALID") |
| 65 | + |
| 66 | + |
| 67 | +def _usage(raw, semantics_id): |
| 68 | + if raw is None: |
| 69 | + return TokenUsage(semantics_id=semantics_id) |
| 70 | + if not isinstance(raw, dict): |
| 71 | + raise ModelAdapterError("MODEL_USAGE_INVALID") |
| 72 | + details = [] |
| 73 | + for key in ("prompt_tokens_details", "completion_tokens_details"): |
| 74 | + value = raw.get(key) |
| 75 | + if value is None: |
| 76 | + value = {} |
| 77 | + if not isinstance(value, dict): |
| 78 | + raise ModelAdapterError("MODEL_USAGE_INVALID") |
| 79 | + details.append(value) |
| 80 | + try: |
| 81 | + return TokenUsage( |
| 82 | + input_tokens=raw.get("prompt_tokens"), |
| 83 | + output_tokens=raw.get("completion_tokens"), |
| 84 | + total_tokens=raw.get("total_tokens"), |
| 85 | + cached_input_tokens=details[0].get("cached_tokens"), |
| 86 | + reasoning_tokens=details[1].get("reasoning_tokens"), |
| 87 | + origin="provider", |
| 88 | + semantics_id=semantics_id, |
| 89 | + ) |
| 90 | + except ValidationError: |
| 91 | + raise ModelAdapterError("MODEL_USAGE_INVALID") from None |
| 92 | + |
| 93 | + |
| 94 | +def _tool_call(raw, index): |
| 95 | + if not isinstance(raw, dict) or raw.get("type") != "function" or not isinstance(raw.get("function"), dict): |
| 96 | + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") |
| 97 | + function = raw["function"] |
| 98 | + arguments = function.get("arguments") |
| 99 | + if not isinstance(arguments, str): |
| 100 | + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") |
| 101 | + parsed, error = None, None |
| 102 | + try: |
| 103 | + parsed = parse_json(arguments) |
| 104 | + if not isinstance(parsed, dict): |
| 105 | + raise ValueError("Tool arguments must be an object") |
| 106 | + except (ValueError, RecursionError): |
| 107 | + parsed, error = None, "INVALID_TOOL_ARGUMENTS" |
| 108 | + try: |
| 109 | + return ToolCall( |
| 110 | + provider_call_id=raw.get("id"), |
| 111 | + index=index, |
| 112 | + name=function.get("name"), |
| 113 | + raw_arguments=arguments, |
| 114 | + parsed_arguments=parsed, |
| 115 | + argument_error=error, |
| 116 | + ) |
| 117 | + except ValidationError: |
| 118 | + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") from None |
| 119 | + |
| 120 | + |
| 121 | +class OpenAIChatAdapter: |
| 122 | + """Single-choice messages with declared parameter bindings and wire metadata.""" |
| 123 | + |
| 124 | + reference = VersionedReference(ref="openai_chat_v1", version=1) |
| 125 | + protocol_reference = VersionedReference(ref="openai_chat_completions", version=1) |
| 126 | + |
| 127 | + def __init__(self, *, reasoning_replay: bool = False): |
| 128 | + if type(reasoning_replay) is not bool: |
| 129 | + raise ValueError("Reasoning replay declaration must be boolean") |
| 130 | + self._reasoning_replay = reasoning_replay |
| 131 | + |
| 132 | + def prepare_http_request(self, request: ModelRequest, profile: ResolvedModelProfile) -> PreparedHttpRequest: |
| 133 | + if request.outlet_id != profile.outlet_id or request.config_revision != profile.config_revision: |
| 134 | + raise ModelAdapterError("MODEL_SNAPSHOT_MISMATCH") |
| 135 | + if profile.bindings.protocol != self.protocol_reference: |
| 136 | + raise ModelAdapterError("MODEL_PROTOCOL_UNSUPPORTED") |
| 137 | + validate_instruction_mapping(profile.instruction_mapping) |
| 138 | + if request.tools and profile.capabilities.tools.status != "supported": |
| 139 | + raise ModelAdapterError("NATIVE_TOOL_PROTOCOL_UNAVAILABLE") |
| 140 | + mappings = profile.instruction_mapping.mappings.model_dump() |
| 141 | + messages = [] |
| 142 | + for message in request.messages: |
| 143 | + role = mappings.get(message.semantic_role, message.semantic_role) |
| 144 | + if role not in profile.instruction_mapping.supported_roles: |
| 145 | + raise ModelAdapterError("MODEL_ROLE_UNSUPPORTED") |
| 146 | + if message.tool_calls and role != "assistant" or message.tool_call_id is not None and role != "tool": |
| 147 | + raise ModelAdapterError("MODEL_MESSAGE_INVALID") |
| 148 | + if role == "tool" and message.tool_call_id is None: |
| 149 | + raise ModelAdapterError("MODEL_MESSAGE_INVALID") |
| 150 | + if ( |
| 151 | + message.tool_calls or message.tool_call_id is not None or role == "tool" |
| 152 | + ) and profile.capabilities.tools.status != "supported": |
| 153 | + raise ModelAdapterError("NATIVE_TOOL_PROTOCOL_UNAVAILABLE") |
| 154 | + parts = message.content_parts |
| 155 | + if any( |
| 156 | + part.get("type") |
| 157 | + not in {"text", "image_url", "input_audio", "audio_url", "video_url", "input_image", "input_video"} |
| 158 | + for part in parts |
| 159 | + ): |
| 160 | + raise ModelAdapterError("MODEL_CONTENT_UNSUPPORTED") |
| 161 | + if any(part.get("type") == "text" and not isinstance(part.get("text"), str) for part in parts): |
| 162 | + raise ModelAdapterError("MODEL_CONTENT_UNSUPPORTED") |
| 163 | + simple_text = len(parts) == 1 and set(parts[0]) == {"type", "text"} |
| 164 | + wire = {"role": role, "content": parts[0]["text"] if simple_text else parts} |
| 165 | + if not parts: |
| 166 | + wire["content"] = None if role == "assistant" and message.tool_calls else "" |
| 167 | + if message.tool_call_id is not None: |
| 168 | + wire["tool_call_id"] = message.tool_call_id |
| 169 | + if message.tool_calls: |
| 170 | + wire["tool_calls"] = [ |
| 171 | + { |
| 172 | + "id": call.provider_call_id, |
| 173 | + "type": "function", |
| 174 | + "function": {"name": call.name, "arguments": call.raw_arguments}, |
| 175 | + } |
| 176 | + for call in message.tool_calls |
| 177 | + ] |
| 178 | + if message.reasoning_parts: |
| 179 | + if ( |
| 180 | + not self._reasoning_replay |
| 181 | + or role != "assistant" |
| 182 | + or any( |
| 183 | + part.get("type") != "text" or not isinstance(part.get("text"), str) |
| 184 | + for part in message.reasoning_parts |
| 185 | + ) |
| 186 | + ): |
| 187 | + raise ModelAdapterError("MODEL_REASONING_FORMAT_UNSUPPORTED") |
| 188 | + wire["reasoning_content"] = "".join(part["text"] for part in message.reasoning_parts) |
| 189 | + messages.append(wire) |
| 190 | + body = {"model": profile.identity.request_model_id, "messages": messages, "stream": request.stream} |
| 191 | + if request.tools: |
| 192 | + body["tools"] = [ |
| 193 | + { |
| 194 | + "type": "function", |
| 195 | + "function": {"name": tool.name, "description": tool.description, "parameters": tool.input_schema}, |
| 196 | + } |
| 197 | + for tool in request.tools |
| 198 | + ] |
| 199 | + if request.stream: |
| 200 | + body["stream_options"] = {"include_usage": True} |
| 201 | + try: |
| 202 | + rules = compile_parameter_rules(profile.parameter_rules) |
| 203 | + validate_saved_parameter_values(rules, profile.parameter_values) |
| 204 | + bindings = validate_parameter_bindings(rules, parameter_transform_references()) |
| 205 | + for key, binding in bindings.items(): |
| 206 | + if key in profile.parameter_values: |
| 207 | + _set(body, _tokens(binding["path"]), transform_parameter(binding, profile.parameter_values[key]), |
| 208 | + binding["path"]) |
| 209 | + body = apply_extra_api_parameters( |
| 210 | + body, profile.extra_api_parameters, parameter_paths=[binding["path"] for binding in bindings.values()] |
| 211 | + ) |
| 212 | + return PreparedHttpRequest( |
| 213 | + request_id=request.request_id, |
| 214 | + method=profile.connection_rules.method, |
| 215 | + base_url=profile.connection.base_url, |
| 216 | + relative_path=profile.connection_rules.relative_path, |
| 217 | + headers=profile.connection_rules.headers, |
| 218 | + credential_ref=profile.connection.credential_ref, |
| 219 | + json_body=body, |
| 220 | + timeout_seconds=profile.connection.timeout_seconds, |
| 221 | + ssl_verify=profile.connection.ssl_verify, |
| 222 | + stream_protocol="sse" if request.stream else None, |
| 223 | + ) |
| 224 | + except (ParameterValidationError, ValueError, TypeError): |
| 225 | + raise ModelAdapterError("MODEL_REQUEST_CONFIGURATION_INVALID") from None |
| 226 | + |
| 227 | + def decode_response( |
| 228 | + self, raw: RawHttpResponse, request: ModelRequest, profile: ResolvedModelProfile |
| 229 | + ) -> AssistantMessage: |
| 230 | + try: |
| 231 | + body = parse_json(raw.body.decode("utf-8")) |
| 232 | + if isinstance(body, dict) and body.get("error") is not None: |
| 233 | + raise ModelAdapterError("MODEL_PROVIDER_ERROR", response=raw) |
| 234 | + if not isinstance(body, dict) or not isinstance(body.get("choices"), list) or len(body["choices"]) != 1: |
| 235 | + raise ModelAdapterError("MODEL_RESPONSE_INVALID") |
| 236 | + choice = body["choices"][0] |
| 237 | + if not isinstance(choice, dict) or type(choice.get("index")) is not int or choice["index"] != 0: |
| 238 | + raise ModelAdapterError("MODEL_RESPONSE_INVALID") |
| 239 | + message = choice.get("message") |
| 240 | + if not isinstance(message, dict) or message.get("role") != "assistant": |
| 241 | + raise ModelAdapterError("MODEL_RESPONSE_INVALID") |
| 242 | + finish = choice.get("finish_reason") |
| 243 | + if not isinstance(finish, str) or not finish.strip(): |
| 244 | + raise ModelAdapterError("MODEL_RESPONSE_INCOMPLETE") |
| 245 | + calls = message.get("tool_calls") |
| 246 | + if calls is None: |
| 247 | + calls = [] |
| 248 | + if not isinstance(calls, list): |
| 249 | + raise ModelAdapterError("MODEL_TOOL_CALL_INVALID") |
| 250 | + parts = _content_parts(message.get("content")) |
| 251 | + if message.get("audio") is not None: |
| 252 | + parts.append({"type": "audio", "audio": message["audio"]}) |
| 253 | + return AssistantMessage( |
| 254 | + content_parts=parts, |
| 255 | + reasoning_parts=_content_parts(message.get("reasoning_content")), |
| 256 | + refusal=message.get("refusal"), |
| 257 | + tool_calls=[_tool_call(call, index) for index, call in enumerate(calls)], |
| 258 | + finish_reason=finish, |
| 259 | + usage=_usage(body.get("usage"), profile.capabilities.usage.semantics_id), |
| 260 | + provider_metadata={ |
| 261 | + "response_id": body.get("id"), |
| 262 | + "model": body.get("model"), |
| 263 | + "request_id": request.request_id, |
| 264 | + "unmapped_message_fields": { |
| 265 | + key: value |
| 266 | + for key, value in message.items() |
| 267 | + if key not in {"role", "content", "reasoning_content", "refusal", "tool_calls", "audio"} |
| 268 | + }, |
| 269 | + }, |
| 270 | + ) |
| 271 | + except ModelAdapterError: |
| 272 | + raise |
| 273 | + except (ValueError, TypeError, UnicodeError, RecursionError): |
| 274 | + raise ModelAdapterError("MODEL_RESPONSE_INVALID") from None |
| 275 | + |
| 276 | + def new_stream_decoder(self, request, profile): |
| 277 | + """Import the stream state lazily to keep complete-message helpers reusable.""" |
| 278 | + from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder |
| 279 | + |
| 280 | + return OpenAIStreamDecoder(request, profile) |
| 281 | + |
| 282 | + def normalize_error(self, error, *, request_id, attempt=1, max_attempts=1, will_retry=False): |
| 283 | + from nexent.core.model_service.errors import normalize_model_error |
| 284 | + |
| 285 | + return normalize_model_error( |
| 286 | + error, request_id=request_id, attempt=attempt, max_attempts=max_attempts, will_retry=will_retry |
| 287 | + ) |
| 288 | + |
| 289 | + def decode_stream(self, raw_events, request, profile): |
| 290 | + """Stop at the verified completion marker; HTTP ownership is external.""" |
| 291 | + decoder = self.new_stream_decoder(request, profile) |
| 292 | + for frame in raw_events: |
| 293 | + for event in decoder.push(frame): |
| 294 | + yield event |
| 295 | + if event.type == "message_completed": |
| 296 | + return |
| 297 | + yield from decoder.finish() |
| 298 | + |
| 299 | + async def adecode_stream(self, raw_events, request, profile): |
| 300 | + decoder = self.new_stream_decoder(request, profile) |
| 301 | + async for frame in raw_events: |
| 302 | + for event in decoder.push(frame): |
| 303 | + yield event |
| 304 | + if event.type == "message_completed": |
| 305 | + return |
| 306 | + for event in decoder.finish(): |
| 307 | + yield event |
0 commit comments