fix(proxy): keep provider refusal, logprobs and system_fingerprint in chat completions (#44788)

* fix(proxy): keep provider refusal, logprobs and system_fingerprint in chat completions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): handle missing chat completion roles

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(types): restore throwaway Usage skip in ModelResponse

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): handle explicit fields on custom response dumps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: update refusal expectations for LIT-9235

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lint): suppress type discipline violations with reasons

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style: ruff format proxy utils

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(types): keep basedpyright counts at merge-base for azure dump and ModelResponse

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(types): keep an explicit null system_fingerprint passed to ModelResponse

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-13 13:17:40 -07:00 • committed by GitHub
parent d7b58126a7
commit 74e5dc9747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 290 additions and 47 deletions

View file

@ -709,7 +709,7 @@ def convert_to_model_response_object(
message = Message(
content=content,
role=choice["message"]["role"] or "assistant",
role=choice["message"].get("role") or "assistant",
function_call=choice["message"].get("function_call", None),
tool_calls=tool_calls,
audio=choice["message"].get("audio", None),
@ -720,6 +720,8 @@ def convert_to_model_response_object(
images=_normalize_images_for_message(choice["message"].get("images", None)),
)
finish_reason = choice.get("finish_reason", None)
if "refusal" in choice["message"]:
message.refusal = choice["message"]["refusal"]
if finish_reason is None:
# gpt-4 vision can return 'finish_reason' or 'finish_details'
finish_reason = choice.get("finish_details") or "stop"
@ -728,9 +730,10 @@ def convert_to_model_response_object(
## PROVIDER SPECIFIC FIELDS ##
provider_specific_fields = {f: choice[f] for f in choice.keys() - _CHOICES_FIELDS}
logprobs = choice.get("logprobs", None)
provider_sent_logprobs = "logprobs" in choice
enhancements = choice.get("enhancements", None)
choice = Choices(
finish_reason=finish_reason,
index=idx,
@ -739,6 +742,9 @@ def convert_to_model_response_object(
enhancements=enhancements,
provider_specific_fields=provider_specific_fields,
)
if provider_sent_logprobs and logprobs is None:
choice.logprobs = None
choice_list.append(choice)
model_response_object.choices = choice_list

View file

@ -13,6 +13,7 @@ from openai import (
BadRequestError,
OpenAI,
)
from pydantic import BaseModel
import litellm
from litellm.constants import AZURE_OPERATION_POLLING_TIMEOUT, DEFAULT_MAX_RETRIES
@ -41,6 +42,7 @@ from ..base import BaseLLM
from ..openai.common_utils import (
build_output_token_limit_response,
is_output_token_limit_error,
model_dump_provider_response,
)
from .common_utils import (
AzureOpenAIError,
@ -352,12 +354,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
headers, response = self.make_sync_azure_openai_chat_completion_request(
azure_client=azure_client, data=data, timeout=timeout
)
if isinstance(response, str):
if not isinstance(response, BaseModel):
raise AzureOpenAIError(
status_code=500,
message=f"Unexpected string response from Azure: {response[:500]}",
message=f"Unexpected response from Azure: {response!r:.500}",
)
stringified_response: Final = response.model_dump()
stringified_response: Final = model_dump_provider_response(response)
## LOGGING
logging_obj.post_call(
input=messages,
@ -447,12 +449,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
)
logging_obj.model_call_details["response_headers"] = headers
if isinstance(response, str):
if not isinstance(response, BaseModel):
raise AzureOpenAIError(
status_code=500,
message=f"Unexpected string response from Azure: {response[:500]}",
message=f"Unexpected response from Azure: {response!r:.500}",
)
stringified_response: Final = response.model_dump()
stringified_response: Final = model_dump_provider_response(response)
logging_obj.post_call(
input=data["messages"],
api_key=api_key,

View file

@ -697,6 +697,10 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
model_response.model = f"{_custom_llm_provider}/{_response_model}"
model_response.id = completion_response["id"]
model_response.created = completion_response["created"]
if "system_fingerprint" in completion_response:
model_response.system_fingerprint = ( # rebind-ok: fills the caller's model_response like id above
completion_response["system_fingerprint"]
)
setattr(model_response, "usage", Usage(**completion_response["usage"]))
served_tier: Final = completion_response.get("service_tier")
if isinstance(served_tier, str) and served_tier:

View file

@ -12,7 +12,7 @@ import time
import uuid
from collections.abc import AsyncIterator, Iterator, Mapping
from contextlib import suppress
from typing import TYPE_CHECKING, Final, Literal, NamedTuple, Optional
from typing import TYPE_CHECKING, Final, Literal, NamedTuple, Optional, Protocol
from urllib.parse import urlsplit
import httpx
@ -23,6 +23,7 @@ from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta
from openai.types.completion_usage import CompletionUsage
from pydantic import BaseModel
if TYPE_CHECKING:
from aiohttp import ClientSession
@ -51,6 +52,16 @@ _AZURE_OPENAI_INIT_PARAMS: Final[tuple[str, ...]] = _get_client_init_params(Azur
_OPENAI_API_HOST: Final[str] = "api.openai.com"
class _ModelDumpable(Protocol):
def model_dump(self) -> dict[str, object]: ... # mutable-ok: pydantic model_dump returns a dict
def model_dump_provider_response(response: _ModelDumpable) -> dict[str, object]: # mutable-ok: callers need a dict
if isinstance(response, BaseModel):
return response.model_dump(exclude_unset=True)
return response.model_dump()
_OPENAI_HTTPX_DEFAULT_TIMEOUT: Final = CompletionTimeout.normalize(openai.DEFAULT_TIMEOUT)
_OPENAI_HTTPX_CONNECTION_LIMITS: Final = httpx.Limits(
max_connections=openai.DEFAULT_CONNECTION_LIMITS.max_connections,

View file

@ -60,6 +60,7 @@ from .common_utils import (
drop_params_from_unprocessable_entity_error,
is_openai_backed_api_base,
is_output_token_limit_error,
model_dump_provider_response,
)
from .workload_identity import resolve_openai_workload_identity_config
@ -852,7 +853,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
)
logging_obj.model_call_details["response_headers"] = headers
stringified_response = provider_config.transform_parsed_response_dict(response.model_dump())
stringified_response = provider_config.transform_parsed_response_dict(
model_dump_provider_response(response)
)
logging_obj.post_call(
input=messages,
api_key=api_key,
@ -990,7 +993,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
timeout=timeout,
logging_obj=logging_obj,
)
stringified_response = provider_config.transform_parsed_response_dict(response.model_dump())
stringified_response = provider_config.transform_parsed_response_dict(
model_dump_provider_response(response)
)
logging_obj.post_call(
input=data["messages"],
api_key=api_key,

View file

@ -111,7 +111,7 @@ except ImportError:
raise ImportError("backoff is not installed. Please install it via 'pip install backoff'")
from fastapi import HTTPException, status
from pydantic import TypeAdapter, ValidationError
from pydantic import BaseModel, TypeAdapter, ValidationError
import litellm
import litellm.litellm_core_utils
@ -9308,12 +9308,15 @@ def validate_model_access(
_PRESERVED_NONE_FIELDS: Final[list[tuple[str, str]]] = [
("message", "content"), # null when tool_calls present (issue #6677)
("message", "role"), # always required by OpenAI spec
("message", "refusal"), # explicit null from provider
("delta", "content"), # null in streaming chunks
]
_PRESERVED_NONE_CHOICE_FIELDS: Final = ("logprobs",) # explicit null from provider
_PRESERVED_NONE_RESPONSE_FIELDS: Final = ("system_fingerprint",) # explicit null from provider
def model_dump_with_preserved_fields(
obj: Any,
obj: BaseModel,
preserve_fields: list[str] | None = None,
exclude_unset: bool = True,
) -> dict[str, object]:
@ -9321,7 +9324,7 @@ def model_dump_with_preserved_fields(
Serialize a Pydantic model to a dictionary while preserving specific fields
even if they are None.
Fields listed in _PRESERVED_NONE_FIELDS are restored after
Explicit null values in the preserved field lists are restored after
model_dump(exclude_none=True) strips them.
Args:
@ -9332,21 +9335,42 @@ def model_dump_with_preserved_fields(
Returns:
Dictionary representation with None values excluded except for preserved fields
"""
result: Final = obj.model_dump(exclude_none=True, exclude_unset=exclude_unset)
result: Final = cast( # cast-ok: Pydantic model_dump returns serialized model fields
dict[str, object],
obj.model_dump(exclude_none=True, exclude_unset=exclude_unset),
)
choices: Final = result.get("choices")
for field_name in _PRESERVED_NONE_RESPONSE_FIELDS:
if field_name not in result and field_name in getattr(obj, "model_fields_set", ()):
result[field_name] = getattr(obj, field_name)
choices: Final = cast( # cast-ok: model_dump serializes choices as a list of dicts
Sequence[dict[str, object]] | None,
result.get("choices"),
)
if not choices:
return result
obj_choices: Final = obj.choices
obj_choices: Final = cast( # cast-ok: obj is duck-typed, choices is a list on ModelResponse
Sequence[object],
getattr(obj, "choices", ()),
)
for choice_obj, choice_dict in zip(obj_choices, choices):
for sub_object, field_name in _PRESERVED_NONE_FIELDS:
sub_dict = choice_dict.get(sub_object)
sub_dict = cast( # cast-ok: message and delta dump as dicts
dict[str, object] | None, choice_dict.get(sub_object)
)
if sub_dict is None:
continue
if field_name not in sub_dict:
sub_obj = getattr(choice_obj, sub_object, None)
sub_obj = cast( # cast-ok: getattr on a duck-typed choice
object | None, getattr(choice_obj, sub_object, None)
)
if sub_obj is not None and hasattr(sub_obj, field_name):
sub_dict[field_name] = getattr(sub_obj, field_name)
for field_name in _PRESERVED_NONE_CHOICE_FIELDS:
if field_name not in choice_dict and hasattr(choice_obj, field_name):
choice_dict[field_name] = getattr(choice_obj, field_name)
return result

View file

@ -2,6 +2,7 @@ from typing import Any, Literal
from pydantic import BaseModel
from typing_extensions import (
NotRequired,
ReadOnly,
Required,
TypedDict,
@ -82,3 +83,4 @@ class DatabricksResponse(TypedDict):
model: str
choices: list[DatabricksChoice]
usage: ChatCompletionUsageBlock
system_fingerprint: NotRequired[ReadOnly[str | None]]

View file

@ -1416,6 +1416,7 @@ def add_provider_specific_fields(object: BaseModel, provider_specific_fields: di
class Message(SafeAttributeModel, OpenAIObject):
content: str | None
role: Literal["assistant", "user", "system", "tool", "function"]
refusal: str | None = None
tool_calls: list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | None
function_call: FunctionCall | None
audio: ChatCompletionAudioResponse | None = None
@ -1493,6 +1494,9 @@ class Message(SafeAttributeModel, OpenAIObject):
if hasattr(self, "annotations"):
del self.annotations
if "refusal" not in params and hasattr(self, "refusal"):
del self.refusal
if reasoning_content is None:
# ensure default response matches OpenAI spec
if hasattr(self, "reasoning_content"):
@ -2212,6 +2216,20 @@ class ModelResponseStream(ModelResponseBase):
return self.dict()
def _normalize_model_response_usage(usage: Mapping[str, object] | BaseModel) -> Usage:
if isinstance(usage, dict):
return Usage(**usage)
dump: Final = usage.model_dump() if hasattr(usage, "model_dump") else usage.dict()
return Usage(**dump)
class _UnsetSystemFingerprint:
__slots__ = ()
_UNSET_SYSTEM_FINGERPRINT: Final = _UnsetSystemFingerprint()
class ModelResponse(ModelResponseBase):
choices: list[Choices]
"""The list of completion choices the model generated for the input prompt."""
@ -2223,7 +2241,7 @@ class ModelResponse(ModelResponseBase):
created=None,
model=None,
object=None,
system_fingerprint=None,
system_fingerprint=_UNSET_SYSTEM_FINGERPRINT,
usage=None,
stream=None,
stream_options=None,
@ -2259,13 +2277,8 @@ class ModelResponse(ModelResponseBase):
created = created
model = model
if usage is not None:
if isinstance(usage, dict):
usage = Usage(**usage)
elif isinstance(usage, BaseModel):
dump = usage.model_dump() if hasattr(usage, "model_dump") else usage.dict()
usage = Usage(**dump)
else:
usage = usage
if isinstance(usage, dict | BaseModel):
usage = _normalize_model_response_usage(usage)
elif stream is None or stream is False:
usage = None # avoid constructing throwaway Usage; set by convert_to_model_response_object
if hidden_params:
@ -2280,11 +2293,12 @@ class ModelResponse(ModelResponseBase):
"created": created,
"model": model,
"object": object,
"system_fingerprint": system_fingerprint,
}
if usage is not None:
init_values["usage"] = usage
if system_fingerprint is not _UNSET_SYSTEM_FINGERPRINT:
init_values["system_fingerprint"] = system_fingerprint
super().__init__(
**init_values,

View file

@ -55,7 +55,7 @@ def test_dashscope_chat_forwards_reasoning_effort_none_to_the_provider(gateway:
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": "27", "provider_specific_fields": {"refusal": None}},
"message": {"role": "assistant", "content": "27"},
"provider_specific_fields": {},
}
]

View file

@ -61,7 +61,6 @@ def test_openai_chat_tool_choice_without_tools_is_not_forwarded(gateway: Gateway
"message": {
"role": "assistant",
"content": "One sentence.",
"provider_specific_fields": {"refusal": None},
},
"provider_specific_fields": {},
}

View file

@ -77,7 +77,6 @@ def test_tencent_thinking_is_sent_in_provider_body_instead_of_failing_the_reques
"role": "assistant",
"content": "43",
"reasoning_content": "17 plus 26 is 43.",
"provider_specific_fields": {"refusal": None},
},
"provider_specific_fields": {},
}

View file

@ -136,7 +136,6 @@ def test_xiaomi_mimo_nonstream_surfaces_reasoning_and_charges_registry_price(gat
"role": "assistant",
"content": "43",
"reasoning_content": "17 plus 26 is 43.",
"provider_specific_fields": {"refusal": None},
},
"provider_specific_fields": {},
}
@ -250,7 +249,6 @@ def test_xiaomi_mimo_tool_call_is_forwarded_and_returned(gateway: Gateway) -> No
"content": None,
"reasoning_content": "Need the tool.",
"tool_calls": [tool_call],
"provider_specific_fields": {"refusal": None},
},
"provider_specific_fields": {},
}

View file

@ -24,7 +24,4 @@ from integration.translation.runner import assert_translation
ids=lambda case: case.id,
)
def test_chat_completions_basic_azure(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
pytest.skip(
"BUG: LIT-9235 chat completions moves message.refusal into provider_specific_fields and drops null system_fingerprint and logprobs"
)
assert_translation(case, gateway, provider)

View file

@ -24,7 +24,4 @@ from integration.translation.runner import assert_translation
ids=lambda case: case.id,
)
def test_chat_completions_basic_openai(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
pytest.skip(
"BUG: LIT-9235 chat completions moves message.refusal into provider_specific_fields and drops null system_fingerprint and logprobs"
)
assert_translation(case, gateway, provider)

View file

@ -1580,10 +1580,8 @@ def test_convert_to_model_response_object_no_provider_specific_fields_still_work
)
assert isinstance(result, ModelResponse)
psf = result.choices[0].message.provider_specific_fields
# refusal is not a Message model field, so it should be in provider_specific_fields
assert psf is not None
assert "refusal" in psf
assert result.choices[0].message.refusal is None
assert result.choices[0].message.provider_specific_fields is None
def test_convert_to_model_response_object_with_error_code_only():
"""

View file

@ -38,6 +38,7 @@ from litellm.utils import _invalidate_model_cost_lowercase_map
from openai.types.beta.assistant import Assistant
from openai.types.beta.assistant_deleted import AssistantDeleted
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
from litellm.proxy.utils import model_dump_with_preserved_fields
@pytest.mark.parametrize(
@ -220,6 +221,49 @@ async def test_acompletion_returns_prompt_cache_details_over_injected_transport(
assert prompt_tokens_details.cached_tokens == 7
@respx.mock
def test_completion_defaults_missing_message_role_and_preserves_omitted_fields() -> None:
route: Final = respx.post("https://api.openai.com/v1/chat/completions").mock(
return_value=httpx.Response(
200,
json={
"id": "chatcmpl-missing-role",
"object": "chat.completion",
"created": 1,
"model": "gpt-5.4",
"choices": [{"index": 0, "message": {"content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
)
response: Final = litellm.completion(
model="openai/gpt-5.4",
api_base="https://api.openai.com/v1",
api_key="sk-test",
messages=[{"role": "user", "content": "Hi"}],
num_retries=0,
)
assert route.called
assert response.choices[0].message.role == "assistant"
assert model_dump_with_preserved_fields(response, exclude_unset=True) == {
"id": "chatcmpl-missing-role",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"content": "hi", "role": "assistant"},
"provider_specific_fields": {},
}
],
"created": 1,
"model": "gpt-5.4",
"object": "chat.completion",
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "expected_error"),

View file

@ -2,12 +2,20 @@
Regression tests for model_dump_with_preserved_fields.
This function serializes ModelResponse / ModelResponseStream objects to dicts
while preserving 3 specific None fields for OpenAI API compatibility:
while preserving specific fields for OpenAI API compatibility:
- choices[*].message.content (null when tool_calls present)
- choices[*].message.role (always present)
- choices[*].delta.content (null in streaming chunks)
- choices[*].message.refusal (explicit null from provider)
- choices[*].logprobs (explicit null from provider)
- system_fingerprint (explicit null from provider)
"""
from typing import Final
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
convert_to_model_response_object,
)
from litellm.proxy.utils import model_dump_with_preserved_fields
from litellm.types.utils import (
Choices,
@ -118,8 +126,23 @@ def test_none_fields_stripped_from_message():
assert "audio" not in msg
def test_none_fields_stripped_from_top_level():
"""system_fingerprint=None should be omitted from top-level."""
def test_unset_system_fingerprint_stripped_from_top_level():
"""A system_fingerprint the provider never sent should be omitted from top-level."""
response = ModelResponse(
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(content="Hello", role="assistant"),
)
],
)
result = model_dump_with_preserved_fields(response, exclude_unset=True)
assert "system_fingerprint" not in result
def test_explicit_none_system_fingerprint_kept_at_top_level():
"""A provider that sent system_fingerprint: null keeps it through ModelResponse(**payload)."""
response = ModelResponse(
choices=[
Choices(
@ -131,7 +154,7 @@ def test_none_fields_stripped_from_top_level():
system_fingerprint=None,
)
result = model_dump_with_preserved_fields(response, exclude_unset=True)
assert "system_fingerprint" not in result
assert result["system_fingerprint"] is None
def test_multiple_choices_independent():
@ -404,3 +427,121 @@ def test_preserve_fields_param_backward_compat():
assert result_default == result_explicit
assert result_default["choices"][0]["message"]["content"] is None
assert result_default["choices"][0]["message"]["role"] == "assistant"
def test_provider_explicit_null_fields_are_preserved() -> None:
response_object: Final = {
"id": "chatcmpl-provider-null-fields",
"created": 1,
"model": "gpt-5.4",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": "Hello", "refusal": None},
"logprobs": None,
}
],
"system_fingerprint": None,
}
model_response: Final = convert_to_model_response_object(
response_object=response_object,
model_response_object=ModelResponse(),
)
assert model_dump_with_preserved_fields(model_response, exclude_unset=True) == {
"id": "chatcmpl-provider-null-fields",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"content": "Hello", "role": "assistant", "refusal": None},
"provider_specific_fields": {},
"logprobs": None,
}
],
"created": 1,
"model": "gpt-5.4",
"object": "chat.completion",
"system_fingerprint": None,
}
def test_provider_omitted_null_fields_stay_absent() -> None:
response_object: Final = {
"id": "chatcmpl-provider-omitted-fields",
"created": 2,
"model": "gpt-5.4",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"role": "assistant", "content": "Hello"},
}
],
}
model_response: Final = convert_to_model_response_object(
response_object=response_object,
model_response_object=ModelResponse(),
)
assert model_dump_with_preserved_fields(model_response, exclude_unset=True) == {
"id": "chatcmpl-provider-omitted-fields",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {"content": "Hello", "role": "assistant"},
"provider_specific_fields": {},
}
],
"created": 2,
"model": "gpt-5.4",
"object": "chat.completion",
}
def test_provider_refusal_string_is_preserved_on_message() -> None:
response_object: Final = {
"id": "chatcmpl-provider-refusal",
"created": 3,
"model": "gpt-5.4",
"object": "chat.completion",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"role": "assistant",
"content": "I can't help with that.",
"refusal": "I can't help with that.",
},
}
],
}
model_response: Final = convert_to_model_response_object(
response_object=response_object,
model_response_object=ModelResponse(),
)
assert model_dump_with_preserved_fields(model_response, exclude_unset=True) == {
"id": "chatcmpl-provider-refusal",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "I can't help with that.",
"role": "assistant",
"refusal": "I can't help with that.",
},
"provider_specific_fields": {},
}
],
"created": 3,
"model": "gpt-5.4",
"object": "chat.completion",
}
assert model_response.choices[0].message.refusal == "I can't help with that."

View file

@ -37616,6 +37616,8 @@ export interface components {
reasoning_content?: string | null;
/** Reasoning Items */
reasoning_items?: components["schemas"]["ChatCompletionReasoningItem"][] | null;
/** Refusal */
refusal?: string | null;
/**
* Role
* @enum {string}