Nancy / models /openai.py
nexusagent-redis's picture
fix: Map gpt-40 to chatgpt to resolve model discrepancy and support direct routing
dc2e1e1
Raw
History Blame Contribute Delete
9.18 kB
"""
Nancy β€” OpenAI-compatible Pydantic v2 schemas.
These models exactly match the OpenAI Chat Completions API response format
so that the official ``openai`` Python SDK works seamlessly.
"""
from __future__ import annotations
import time
import uuid
from typing import Literal
from pydantic import BaseModel, Field
# ── Helpers ───────────────────────────────────────────────────────────────────
def _chatcmpl_id() -> str:
"""Generate an OpenAI-style completion ID."""
return f"chatcmpl-{uuid.uuid4().hex[:29]}"
def _unix_ts() -> int:
"""Current UTC Unix timestamp."""
return int(time.time())
# ── Request Models ────────────────────────────────────────────────────────────
class ChatMessage(BaseModel):
"""A single chat message in the OpenAI format."""
role: Literal["system", "user", "assistant", "function", "tool"] = Field(
..., description="The role of the message author."
)
content: str | None = Field(
default=None, description="The text content of the message."
)
name: str | None = Field(
default=None, description="Optional name for the message author."
)
tool_call_id: str | None = Field(
default=None, description="Tool call that this message is responding to."
)
class ChatCompletionRequest(BaseModel):
"""
Incoming request body for ``POST /v1/chat/completions``.
Mirrors the subset of OpenAI fields that Nancy supports.
"""
model: str = Field(
..., description="Model/provider name, e.g. 'chatgpt', 'gemini'."
)
messages: list[ChatMessage] = Field(
..., min_length=1, description="Conversation messages."
)
stream: bool = Field(
default=False, description="Whether to stream the response via SSE."
)
temperature: float | None = Field(
default=None, ge=0.0, le=2.0, description="Sampling temperature."
)
max_tokens: int | None = Field(
default=None, ge=1, description="Maximum tokens to generate."
)
top_p: float | None = Field(
default=None, ge=0.0, le=1.0, description="Nucleus sampling parameter."
)
stop: str | list[str] | None = Field(
default=None, description="Stop sequences."
)
user: str | None = Field(
default=None, description="End-user identifier for abuse tracking."
)
tools: list[dict] | None = Field(
default=None, description="A list of tools the model may call."
)
tool_choice: str | dict | None = Field(
default=None, description="Controls which (if any) tool is called."
)
# ── Response Models β€” Non-streaming ──────────────────────────────────────────
class UsageInfo(BaseModel):
"""Token usage statistics."""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class ChoiceMessage(BaseModel):
"""The assistant's response message in a non-streaming completion."""
role: Literal["assistant"] = "assistant"
content: str | None = ""
tool_calls: list[dict] | None = Field(
default=None, description="The tool calls generated by the model."
)
class Choice(BaseModel):
"""A single choice in a non-streaming completion response."""
index: int = 0
message: ChoiceMessage = Field(default_factory=ChoiceMessage)
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"] | None = None
class ChatCompletionResponse(BaseModel):
"""
Non-streaming response for ``POST /v1/chat/completions``.
Matches ``openai.types.chat.ChatCompletion``.
"""
id: str = Field(default_factory=_chatcmpl_id)
object: Literal["chat.completion"] = "chat.completion"
created: int = Field(default_factory=_unix_ts)
model: str = ""
choices: list[Choice] = Field(default_factory=lambda: [Choice()])
usage: UsageInfo = Field(default_factory=UsageInfo)
system_fingerprint: str | None = None
@classmethod
def from_content(
cls,
content: str,
model: str,
finish_reason: str = "stop",
) -> ChatCompletionResponse:
"""Build a complete response from a single content string."""
return cls(
model=model,
choices=[
Choice(
index=0,
message=ChoiceMessage(content=content),
finish_reason=finish_reason, # type: ignore[arg-type]
)
],
usage=UsageInfo(
prompt_tokens=0,
completion_tokens=len(content.split()),
total_tokens=len(content.split()),
),
)
# ── Response Models β€” Streaming (SSE chunks) ─────────────────────────────────
class DeltaContent(BaseModel):
"""
Delta object inside a streaming chunk.
On the first chunk, ``role`` is set to ``"assistant"`` with no content.
On subsequent chunks, ``content`` carries the text fragment.
On the final chunk, both may be absent (empty delta).
"""
role: Literal["assistant"] | None = None
content: str | None = None
tool_calls: list[dict] | None = Field(
default=None, description="The tool calls generated by the model."
)
class StreamChoice(BaseModel):
"""A single choice in a streaming chunk."""
index: int = 0
delta: DeltaContent = Field(default_factory=DeltaContent)
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"] | None = None
class ChatCompletionChunk(BaseModel):
"""
A single SSE chunk for streaming ``POST /v1/chat/completions``.
Matches ``openai.types.chat.ChatCompletionChunk``.
"""
id: str = Field(default_factory=_chatcmpl_id)
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
created: int = Field(default_factory=_unix_ts)
model: str = ""
choices: list[StreamChoice] = Field(default_factory=lambda: [StreamChoice()])
system_fingerprint: str | None = None
def to_sse_data(self) -> str:
"""Serialize to the JSON string used in ``data: ...`` SSE frames."""
return self.model_dump_json(exclude_none=False)
# ── Convenience factories ─────────────────────────────────────────
@classmethod
def first_chunk(cls, completion_id: str, model: str) -> ChatCompletionChunk:
"""Role-only opening chunk (no content)."""
return cls(
id=completion_id,
model=model,
choices=[
StreamChoice(
delta=DeltaContent(role="assistant"),
finish_reason=None,
)
],
)
@classmethod
def content_chunk(
cls, completion_id: str, model: str, content: str
) -> ChatCompletionChunk:
"""A chunk carrying a text fragment."""
return cls(
id=completion_id,
model=model,
choices=[
StreamChoice(
delta=DeltaContent(content=content),
finish_reason=None,
)
],
)
@classmethod
def final_chunk(
cls,
completion_id: str,
model: str,
finish_reason: str = "stop",
) -> ChatCompletionChunk:
"""Terminal chunk with ``finish_reason`` and empty delta."""
return cls(
id=completion_id,
model=model,
choices=[
StreamChoice(
delta=DeltaContent(),
finish_reason=finish_reason, # type: ignore[arg-type]
)
],
)
# ── /v1/models response ──────────────────────────────────────────────────────
class ModelInfo(BaseModel):
"""A single model entry returned by ``GET /v1/models``."""
id: str
object: Literal["model"] = "model"
created: int = Field(default_factory=_unix_ts)
owned_by: str = "nancy"
class ModelListResponse(BaseModel):
"""Response body for ``GET /v1/models``."""
object: Literal["list"] = "list"
data: list[ModelInfo] = Field(default_factory=list)
# ── Error response ────────────────────────────────────────────────────────────
class ErrorDetail(BaseModel):
"""OpenAI-style error detail."""
message: str
type: str = "invalid_request_error"
param: str | None = None
code: str | None = None
class ErrorResponse(BaseModel):
"""OpenAI-style error envelope."""
error: ErrorDetail