# -*- coding: utf-8 -*-
"""NHÀ CUNG CẤP TƯƠNG THÍCH OPENAI — cho mọi cổng nói giao thức OpenAI.

VÌ SAO cần tệp riêng biệt với anthropic.py dù cùng "gọi HTTP tới LLM": chủ
dự án hay mua lại quyền dùng Gemini/DeepSeek... qua các nguồn bán lại rẻ,
và những nguồn đó hầu như luôn expose một cổng giả lập
`POST {goc}/chat/completions` kiểu OpenAI thay vì API gốc của hãng — viết
MỘT lớp nói được giao thức này là dùng được cho MỌI nguồn kiểu đó, không
cần thêm tệp mỗi lần đổi nhà bán lại, chỉ cần đổi `LLM_GOC`/`LLM_KHOA`.

CẢNH BÁO quan trọng: nguồn bán lại có thể ÂM THẦM định tuyến câu hỏi sang
một mô hình rẻ hơn mô hình đã bán (vd khách trả tiền cho "GPT-4 class" mà
thực chất nhận câu trả lời từ một mô hình nhỏ hơn để bên bán ăn chênh lệch,
hoặc tự động hạ cấp khi mô hình gốc quá tải). Vì vậy lớp này KHÔNG được chỉ
ghi lại model đã GỬI ĐI trong request — nó phải đọc và ghi lại đúng field
`model` mà máy chủ TRẢ VỀ trong phản hồi (nếu có), để soi lỗi/đối chiếu
sau này khi nghi ngờ bị tráo mô hình. Cùng lúc, mỗi lượt gọi còn ghi lại
số liệu (token vào/ra, thời gian) trên chính đối tượng — xem
`model_thuc_te_gan_nhat` và các thuộc tính `..._gan_nhat` khác ở __init__.

GHI CHÚ VỀ CỔNG THẬT ĐÃ ĐO (22/09/2026, dùng làm căn cứ, không đo lại ở
đây): `GET /v1/models` của hhtechapi.net liệt kê 113 model nhưng đó KHÔNG
PHẢI danh sách được phép gọi — gọi thật nhiều model trong đó trả về
HTTP 403 "Model ... không có trong bảng giá hiện hành." Vì lỗi 403 kiểu
này xảy ra ngay giữa lúc khách đang chat chứ không lộ ra khi soát cấu hình,
xem `thu/kiem_cong.py` (script kiểm cổng chạy tay trước khi triển khai) và
`LLM_MODEL_DU_PHONG` (model dự phòng khi model chính hỏng hạ tầng).
"""
from __future__ import annotations

import logging
import os
import sys
import threading
import time
from typing import List, Optional

try:
    from .giao_dien import NhaCungCap, LoiNhaCungCap, dung_prompt_dien_giai, dung_prompt_phan_loai, CAC_NHAN_YDINH, huong_dan_anh
    from ..giao_uoc import NguCanh, KetQuaTim, Ydinh
except ImportError:
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    from llm.giao_dien import (  # type: ignore
        NhaCungCap, LoiNhaCungCap, dung_prompt_dien_giai, dung_prompt_phan_loai, CAC_NHAN_YDINH, huong_dan_anh,
    )
    from giao_uoc import NguCanh, KetQuaTim, Ydinh  # type: ignore


_log = logging.getLogger('chatbot.llm.tuongthich_openai')

_THOI_GIAN_CHO = 30.0  # giây
_THOI_GIAN_CHO_MANH = 120.0  # giây — mô hình tư duy cao (Opus, Sonnet...) nghĩ lâu hơn

# Do THAT tren cau hoi that cua khach (22/09/2026), token_ra tung luot:
#     305 · 419 · 426 · 510 · 513 · 543 · 597 · 700 · 724 · 1019
# Cum tu nhien nam trong khoang 300-600, nhung co duoi keo toi ~1000. O tran
# 700 thi do duoc 1 tren 4-5 luot bi CAT NGANG (finish_reason='length') —
# khach nhan mot cau dut giua chu. Nang len 1100 de phu ca phan duoi.
#
# Chi phi khong dang ke: mot luot ton ~2.500-3.500 token VAO, phan RA chi la
# thieu so. Doi lai, cau tra loi khong bao gio cut ngang.
#
# Neu sau nay muon cau tra loi NGAN hon (bong chat doc de hon) thi sua CAU DAN
# trong llm/giao_dien.py, DUNG ha tran nay xuong — ha tran khong lam mo hinh
# noi gon lai, no chi cat giua chung.
#
# NANG 23/09/2026 theo chu du an: "chi phi token hien tai rat re, chung ta
# can chat luong hon". Tran chi con de chan mo hinh chay mat kiem soat —
# tran KHONG phai muc tieu, mo hinh dung bao nhieu tinh bay nhieu. Do dai
# cau tra loi do CAU DAN quyet dinh, khong phai con so nay.
# 3000 -> 8000 cung ngay: deepseek-v4-flash co buoc "suy nghi" AN tinh vao
# token ra — do that mot cau tra loi ~250 tu ton 3000 token ra, cham tran,
# bi cat giua dong khai nguon va lo chu "NGU" ra cho khach.
# 02/10/2026 chu du an: "bo gioi han tran 4000 token" - TOM_TAT (tom tat ho so, soan tin cham soc, viet bai, ban tin)
# con 4000 tu truoc nen deepseek cham tran lien tuc (buoc suy nghi an an het), tra RONG -> chuyen Opus du phong.
# Hai tran nay nay chi con de chan mo hinh chay mat kiem soat.
_MAX_TOKENS_DIEN_GIAI = 32000
_MAX_TOKENS_PHAN_LOAI = 16
_MAX_TOKENS_TOM_TAT = 32000
# Luot TOM_TAT chay NEN (khong co khach dang cho): cho lau hon, khoi coi bai dai la "ha tang hong" roi chuyen du phong.
_THOI_GIAN_CHO_NEN = 180.0


def _mo_hinh_cai_dat(vai: str, khong_gian: str = 'tradingauto') -> str:
    """Mô hình theo vai trong Cài đặt website; rỗng nếu chưa cấu hình / lỗi."""
    try:
        from congcu.cau_hinh_ai import mo_hinh
        return mo_hinh(vai, khong_gian or 'tradingauto')
    except Exception:  # noqa: BLE001 - cấu hình hỏng thì dùng .env như cũ
        return ''


def _la_manh(model: str) -> bool:
    try:
        from congcu.cau_hinh_ai import la_mo_hinh_manh
        return la_mo_hinh_manh(model)
    except Exception:  # noqa: BLE001
        return False


def _cho(model: str) -> float:
    try:
        from congcu.cau_hinh_ai import can_cho_lau
        return _THOI_GIAN_CHO_MANH if can_cho_lau(model) else _THOI_GIAN_CHO
    except Exception:  # noqa: BLE001
        return _THOI_GIAN_CHO

# ĐỪNG GỠ dòng User-Agent này. Một hệ thống anh em dùng chung kiểu cổng bán
# lại này (không nằm trong repo này) đã ăn HTTP 403 "error code: 1010" từ
# Cloudflare khi request mang User-Agent MẶC ĐỊNH của thư viện HTTP —
# trông y hệt một lỗi xác thực (khoá sai/hết hạn) nên rất tốn thời gian mới
# lần ra là Cloudflare chặn theo CHỮ KÝ CLIENT, không liên quan gì tới
# khoá. Đo lại hôm 22/09/2026 thì cổng .net này chưa chặn kiểu đó, nhưng
# đặt sẵn một User-Agent riêng chỉ tốn một dòng mà tránh được cả buổi
# debug nhầm hướng nếu Cloudflare đổi luật sau này.
_USER_AGENT = 'tradingauto-chatbot/1.0'

# Mã HTTP coi là "hỏng hạ tầng" ngoài dải 5xx — xem _LoiHaTang bên dưới.
_MA_HTTP_HA_TANG_KHAC_5XX = (429, 403)


def chuan_hoa_goc(url: str) -> str:
    """Chuẩn hoá base_url của cổng: bỏ mọi dấu '/' thừa ở cuối VÀ bỏ mọi
    đuôi '/v1' lặp lại ở cuối, để nơi gọi LUÔN tự ghép đúng một lần
    '/v1/chat/completions'.

    VÌ SAO cần hàm riêng: trước đây lớp chỉ làm `goc.rstrip('/')` rồi ghép
    thẳng `/chat/completions` — nghĩa là LLM_GOC bắt buộc phải ghi kèm
    sẵn '/v1', ghi thừa ('.../v1/v1/chat/completions') hay thiếu
    ('.../chat/completions', thiếu tiền tố '/v1' mà đa số cổng kiểu OpenAI
    đòi) đều gọi sai endpoint và người cấu hình không có cách nào biết
    trước. Chuẩn hoá ở đây khiến 'https://hhtechapi.net',
    'https://hhtechapi.net/', 'https://hhtechapi.net/v1' và
    'https://hhtechapi.net/v1/' đều ra CÙNG một base — xem tự kiểm cuối
    tệp.
    """
    goc = (url or '').strip().rstrip('/')
    while goc.endswith('/v1'):
        goc = goc[:-len('/v1')].rstrip('/')
    return goc


def che_khoa(k: Optional[str]) -> str:
    """Che khoá API để dùng trong MỌI thông báo lỗi/dòng log của lớp này —
    không bao giờ ghi nguyên khoá ra log hay thông báo lỗi. Giữ 6 ký tự
    đầu + '…' + 4 ký tự cuối (vd 'sk-412…4cc6'); khoá ngắn hơn 14 ký tự
    (không đủ để che có ý nghĩa) chỉ trả về '…'."""
    if not k or len(k) < 14:
        return '…'
    return f'{k[:6]}…{k[-4:]}'


class _LoiHaTang(LoiNhaCungCap):
    """Lỗi HẠ TẦNG — timeout, lỗi mạng, HTTP 5xx, HTTP 429, HTTP 403, hoặc
    phản hồi rỗng. CHỈ những lỗi thuộc lớp con này mới được phép kích hoạt
    việc thử lại bằng model dự phòng (xem `NhaCungCapTuongThichOpenAI._goi`
    và chú thích dài ở đó về vì sao ranh giới này quan trọng). Lỗi định
    dạng phản hồi (JSON hỏng/thiếu field) hay bất kỳ lỗi nào khác vẫn ném
    `LoiNhaCungCap` (lớp cha) như cũ — KHÔNG được gói vào đây, vì chúng
    không phải "hạ tầng hỏng" và thử lại bằng model khác không sửa được gì."""
    pass


class NhaCungCapTuongThichOpenAI(NhaCungCap):
    """Gọi bất kỳ cổng nào nói giao thức `chat/completions` kiểu OpenAI.

    Cấu hình hoàn toàn bằng biến môi trường — không viết cứng base url hay
    tên mô hình, vì đây chính là lớp dùng để "đổi nhà cung cấp bằng cấu
    hình" khi nguồn bán lại đang dùng chết bất ngờ.
    """

    ten = 'tuongthich_openai'

    def __init__(self) -> None:
        goc = os.environ.get('LLM_GOC', '').strip()
        khoa = os.environ.get('LLM_KHOA', '').strip()
        model_dien_giai = os.environ.get('LLM_MODEL_DIEN_GIAI', '').strip()

        thieu = [
            ten_bien for ten_bien, gia_tri in (
                ('LLM_GOC', goc), ('LLM_KHOA', khoa), ('LLM_MODEL_DIEN_GIAI', model_dien_giai),
            ) if not gia_tri
        ]
        if thieu:
            # Ném ngay lúc khởi tạo — như anthropic.py — để lỗi cấu hình
            # lộ ra khi khởi động tiến trình, không phải giữa lượt chat.
            raise LoiNhaCungCap(
                'Thiếu biến môi trường cấu hình nhà cung cấp tương thích OpenAI: '
                f"{', '.join(thieu)}. Kiểm tra .env — cần đủ LLM_GOC, LLM_KHOA, "
                'LLM_MODEL_DIEN_GIAI.'
            )

        # LLM_MODEL_PHAN_LOAI KHÔNG còn bắt buộc. Lý do nới ra: đã grep xác
        # nhận nao/tra_loi.py chỉ gọi nao.dinh_tuyen.phan_loai(cau_hoi,
        # ngu_canh) — KHÔNG truyền tham số nha_cung_cap — nên nhánh
        # `if nha_cung_cap is not None: nha_cung_cap.phan_loai(...)` bên
        # trong dinh_tuyen.phan_loai() (tham số nha_cung_cap=None mặc
        # định) không bao giờ chạy tới ở giai đoạn hiện tại; phân loại ý
        # định hoàn toàn bằng LUẬT. Bắt buộc một biến môi trường cho một
        # đường gọi chưa từng được kích hoạt chỉ khiến cấu hình dễ vỡ hơn
        # cần thiết (thiếu biến này từng làm CẢ nhà cung cấp rơi về
        # NhaCungCapRong dù dien_giai() vẫn dùng được bình thường). Mặc
        # định dùng CHUNG model diễn giải, để nếu giai đoạn sau có bật
        # nhánh gọi mô hình phân loại thì vẫn chạy được ngay, không cần
        # thêm biến môi trường mới.
        model_phan_loai = os.environ.get('LLM_MODEL_PHAN_LOAI', '').strip() or model_dien_giai

        self._goc = chuan_hoa_goc(goc)
        self._khoa = khoa
        self._model_phan_loai = model_phan_loai
        self._model_dien_giai = model_dien_giai
        # Model dự phòng — TUỲ CHỌN. Rỗng/không đặt thì hành vi y hệt trước
        # đây: hỏng là ném LoiNhaCungCap thẳng, không thử lại model nào
        # khác (xem chú thích trong _goi() về ranh giới "hạ tầng hỏng" vs
        # "câu trả lời không vừa ý").
        self._model_du_phong = os.environ.get('LLM_MODEL_DU_PHONG', '').strip() or None

        # Số liệu của lượt gọi GẦN NHẤT — chỉ cập nhật khi có một lượt gọi
        # THÀNH CÔNG (chính hoặc dự phòng); lượt gọi lỗi hoàn toàn không
        # đụng tới các thuộc tính này, để "..._gan_nhat" luôn phản ánh lượt
        # thành công gần nhất thay vì bị ghi đè thành None mỗi lần lỗi.
        # ... và chúng nằm trong một kho RIÊNG THEO LUỒNG, không phải
        # thuộc tính thường của đối tượng. VÌ SAO:
        #
        # Lớp này là SINGLETON dùng chung cho cả tiến trình (xem
        # nao/tra_loi.py::_lay_nha_cung_cap_dung_chung). Nếu năm số liệu
        # dưới đây là thuộc tính thường, thì hai yêu cầu chạy song song sẽ
        # ghi đè lên nhau: luồng A gọi xong, luồng B gọi và ĐÈ, rồi A mới
        # đọc — A lấy phải token của B. Hậu quả không hề lộ ra: câu trả lời
        # vẫn đúng, chỉ có bảng chi phí trong trang quản trị là sai, và sai
        # theo kiểu không ai đối chiếu được.
        #
        # Hiện tại app.py gọi lõi ĐỒNG BỘ trong hàm async nên mọi yêu cầu
        # bị xếp hàng và chưa thể đua. Nhưng đó là một tính chất TÌNH CỜ
        # của một chỗ hoàn toàn khác, và nó đáng bị sửa (gọi đồng bộ khoá
        # cả cổng). Ai sửa chỗ đó mà không biết tới đoạn này thì tự nhiên
        # số liệu bắt đầu sai. Kho theo luồng khiến điều đó không xảy ra
        # được, và tốn đúng mấy dòng.
        self._rieng = threading.local()

    # Năm thuộc tính "..._gan_nhat" giữ nguyên tên và cách dùng như cũ —
    # bên ngoài đọc `ncc.token_vao_gan_nhat` y hệt trước, chỉ khác là mỗi
    # luồng thấy số của chính nó.
    @property
    def model_thuc_te_gan_nhat(self) -> Optional[str]:
        return getattr(self._rieng, 'model_thuc_te', None)

    @property
    def model_da_gui_gan_nhat(self) -> Optional[str]:
        return getattr(self._rieng, 'model_da_gui', None)

    @property
    def token_vao_gan_nhat(self) -> Optional[int]:
        return getattr(self._rieng, 'token_vao', None)

    @property
    def token_ra_gan_nhat(self) -> Optional[int]:
        return getattr(self._rieng, 'token_ra', None)

    @property
    def thoi_gian_ms_gan_nhat(self) -> Optional[float]:
        return getattr(self._rieng, 'thoi_gian_ms', None)

    def _duong_dan_day_du(self) -> str:
        return f'{self._goc}/v1/chat/completions'

    def _an_khoa_trong_van_ban(self, van_ban: str) -> str:
        """Nếu khoá API vô tình xuất hiện nguyên văn trong một chuỗi (vd
        cổng echo lại Authorization trong thông báo lỗi — đã thấy kiểu này
        ở một số cổng bán lại tương tự), thay nó bằng dạng che trước khi
        đưa vào log/LoiNhaCungCap. Trước đây lớp này dán thẳng 500 ký tự
        phản hồi thô của máy chủ vào thông báo lỗi mà không rà soát khả
        năng lộ khoá — sửa ở đây."""
        if not van_ban or not self._khoa:
            return van_ban
        return van_ban.replace(self._khoa, che_khoa(self._khoa))

    def _goi_mot_lan(self, model_gui: str, cau_dan_he_thong: str, cau_nguoi_dung: str, max_tokens: int):
        """Gọi cổng ĐÚNG MỘT LẦN bằng `model_gui`. Trả về tuple
        (van_ban, model_thuc_te, token_vao, token_ra, thoi_gian_ms).

        Ném `_LoiHaTang` cho các lỗi "hạ tầng hỏng" (timeout/mạng/5xx/429/
        403/rỗng) — cho phép `_goi()` thử lại bằng model dự phòng. Ném
        `LoiNhaCungCap` thường cho mọi lỗi khác (vd định dạng phản hồi sai)
        — KHÔNG được thử lại.
        """
        import httpx  # import trễ — xem lý do ở anthropic.py

        than_bai = {
            'model': model_gui,
            'max_tokens': max_tokens,
            'messages': [
                {'role': 'system', 'content': cau_dan_he_thong},
                {'role': 'user', 'content': cau_nguoi_dung},
            ],
        }
        tieu_de = {
            'Authorization': f'Bearer {self._khoa}',
            'Content-Type': 'application/json',
            # ĐỪNG GỠ — xem giải thích HTTP 403 "error code: 1010" của
            # Cloudflare ở đầu tệp (chỗ khai báo _USER_AGENT).
            'User-Agent': _USER_AGENT,
        }

        bat_dau = time.perf_counter()
        try:
            phan_hoi = httpx.post(
                self._duong_dan_day_du(), json=than_bai, headers=tieu_de,
                timeout=max(_cho(model_gui), _THOI_GIAN_CHO_NEN) if max_tokens == _MAX_TOKENS_TOM_TAT else _cho(model_gui)
            )
            phan_hoi.raise_for_status()
        except httpx.TimeoutException as loi:
            raise _LoiHaTang(
                f'Nguồn tương thích OpenAI ({self._goc}) không phản hồi sau {_cho(model_gui)}s '
                f'(model={model_gui}, khoá={che_khoa(self._khoa)}): {loi}'
            ) from loi
        except httpx.HTTPStatusError as loi:
            ma = loi.response.status_code
            # Rà soát lộ khoá: một số cổng echo lại header/khoá trong thân
            # lỗi khi xác thực thất bại — che trước khi cắt 500 ký tự và
            # đưa vào thông báo, không dán thô phản hồi máy chủ như trước.
            van_ban_loi = self._an_khoa_trong_van_ban(loi.response.text)[:500]
            thong_bao = (
                f'Nguồn tương thích OpenAI trả lỗi HTTP {ma} (model={model_gui}, '
                f'khoá={che_khoa(self._khoa)}): {van_ban_loi}'
            )
            if ma >= 500 or ma in _MA_HTTP_HA_TANG_KHAC_5XX:
                raise _LoiHaTang(thong_bao) from loi
            raise LoiNhaCungCap(thong_bao) from loi
        except httpx.HTTPError as loi:
            raise _LoiHaTang(
                f'Lỗi mạng khi gọi nguồn tương thích OpenAI ({self._goc}, model={model_gui}): {loi}'
            ) from loi
        thoi_gian_ms = (time.perf_counter() - bat_dau) * 1000.0

        try:
            du_lieu = phan_hoi.json()
            # Ghi lại model THẬT trả về, KHÔNG chỉ tin model đã gửi đi —
            # đây là chỗ bắt được nguồn bán lại tráo mô hình rẻ hơn.
            model_thuc_te = du_lieu.get('model')
            _lua_chon = du_lieu['choices'][0]
            van_ban = _lua_chon['message']['content']
            # `finish_reason == 'length'` nghĩa là mô hình BỊ CẮT vì chạm trần
            # max_tokens, chứ không phải nó nói xong. Không đọc field này thì
            # cắt là CẮT IM LẶNG: khách nhận một câu đứt giữa chừng, log chỉ
            # thấy một lượt gọi thành công, không có gì để lần ra.
            ly_do_dung = _lua_chon.get('finish_reason')
        except (ValueError, KeyError, IndexError, TypeError) as loi:
            raise LoiNhaCungCap(
                f'Phản hồi từ nguồn tương thích OpenAI không đúng định dạng mong đợi '
                f'(model={model_gui}, khoá={che_khoa(self._khoa)}): {loi}'
            ) from loi

        if not van_ban or not van_ban.strip():
            # Phản hồi rỗng NẰM TRONG danh sách "hạ tầng hỏng" (đề bài) —
            # được phép thử lại bằng model dự phòng.
            raise _LoiHaTang(
                f'Nguồn tương thích OpenAI trả về nội dung rỗng (model={model_gui}).'
            )

        # Đọc token vào/ra — tên field chuẩn OpenAI trước, rồi dự phòng
        # sang tên khác (input_tokens/output_tokens) vì cổng bán lại có
        # thể không theo đúng chuẩn.
        su_dung = du_lieu.get('usage') or {}
        token_vao = su_dung.get('prompt_tokens', su_dung.get('input_tokens'))
        token_ra = su_dung.get('completion_tokens', su_dung.get('output_tokens'))

        if ly_do_dung == 'length':
            _log.warning(
                'CÂU TRẢ LỜI BỊ CẮT vì chạm trần max_tokens=%s (model=%s, token_ra=%s). '
                'Khách nhận một câu đứt giữa chừng. Nếu dòng này xuất hiện thường '
                'xuyên thì phải nâng trần trong _MAX_TOKENS_DIEN_GIAI, hoặc sửa câu '
                'dẫn để mô hình trả lời gọn hơn — đừng bỏ qua.',
                max_tokens, model_gui, token_ra,
            )

        return van_ban.strip(), model_thuc_te, token_vao, token_ra, thoi_gian_ms

    def _goi(self, model_gui: str, cau_dan_he_thong: str, cau_nguoi_dung: str, max_tokens: int) -> str:
        # ====================================================================
        # VÌ SAO chỉ thử lại ĐÚNG MỘT LẦN, và CHỈ khi _goi_mot_lan() ném
        # _LoiHaTang (timeout/mạng/5xx/429/403/rỗng — xem định nghĩa ở lớp
        # _LoiHaTang phía trên):
        #
        # Đi hỏi hết model này tới model khác CHO TỚI KHI có câu trả lời
        # VỪA Ý là "mua câu trả lời" (shopping for an answer) — và đó là
        # cách tự lừa nguy hiểm nhất có thể cài vào một hệ đang bán hàng
        # thật. Nó biến "model A trả lời một câu không như ý" thành lý do
        # hợp lệ để hỏi model B, rồi model C..., cho tới khi có câu "nghe
        # xuôi tai" — nhưng "nghe xuôi tai" không đồng nghĩa "đúng sự thật
        # trong tài liệu", và một hệ tự động chọn câu trả lời theo tiêu chí
        # "nghe xuôi tai nhất" thay vì "đúng nhất" là một hệ đang tự lừa
        # chính nó, rồi lừa luôn khách hàng thật đang đọc câu trả lời đó.
        #
        # Vì vậy: KHÔNG mã nào trong lớp này được phép gọi lại chỉ vì đã
        # đọc thấy NỘI DUNG câu trả lời của model đầu và thấy nó "chưa đủ
        # tốt". Quyền gọi lại CHỈ được trao khi hạ tầng rõ ràng đã hỏng —
        # tức là model KHÔNG HỀ có cơ hội trả lời (timeout, đứt mạng, máy
        # chủ báo lỗi/quá tải/từ chối truy cập, hoặc trả về rỗng) — chứ
        # không phải vì nó đã trả lời rồi mà câu đó không hợp ý.
        # ====================================================================
        model_dung = model_gui
        # Dự phòng theo sức mô hình chính: Opus hỏng thì sang một mô hình mạnh
        # KHÁC HÃNG, không rơi xuống mô hình rẻ (congcu/cau_hinh_ai.py).
        du_phong = ((_mo_hinh_cai_dat('du_phong_manh') if _la_manh(model_gui) else '')
                    or _mo_hinh_cai_dat('du_phong') or self._model_du_phong)
        try:
            van_ban, model_tt, tv, tr, ms = self._goi_mot_lan(
                model_gui, cau_dan_he_thong, cau_nguoi_dung, max_tokens
            )
        except _LoiHaTang as loi_chinh:
            if not du_phong or du_phong == model_gui:
                # Không có (hoặc dự phòng trùng model chính, thử lại vô ích) -
                # hành vi y hệt trước đây: ném thẳng ra ngoài.
                raise
            _log.warning(
                'Model chính %s hỏng hạ tầng, chuyển sang model dự phòng %s. Lỗi: %s',
                model_gui, du_phong, loi_chinh,
            )
            try:
                van_ban, model_tt, tv, tr, ms = self._goi_mot_lan(
                    du_phong, cau_dan_he_thong, cau_nguoi_dung, max_tokens
                )
                model_dung = du_phong
            except LoiNhaCungCap as loi_du_phong:
                # Dự phòng CŨNG lỗi -> ném ra, KHÔNG thử thêm lần nào nữa
                # (đúng "thử lại ĐÚNG MỘT LẦN").
                raise LoiNhaCungCap(
                    f'Model chính ({model_gui}) hỏng hạ tầng: {loi_chinh}. '
                    f'Model dự phòng ({du_phong}) cũng lỗi: {loi_du_phong}'
                ) from loi_du_phong

        self._rieng.model_da_gui = model_dung
        self._rieng.model_thuc_te = model_tt
        self._rieng.token_vao = tv
        self._rieng.token_ra = tr
        self._rieng.thoi_gian_ms = ms
        _log.info(
            'Gọi LLM xong: model_gui=%s model_tra_ve=%s token_vao=%s token_ra=%s thoi_gian_ms=%.0f',
            model_dung, model_tt, tv, tr, ms,
        )
        if model_tt and model_tt != model_dung:
            _log.warning(
                'NGHI BỊ TRÁO MODEL: đã gửi %r nhưng máy chủ trả về model=%r trong phản hồi — '
                'đối chiếu lại với bên bán nếu việc này lặp lại.',
                model_dung, model_tt,
            )
        return van_ban

    def phan_loai(self, cau_hoi: str, ngu_canh: NguCanh) -> Ydinh:
        he_thong, nguoi_dung = dung_prompt_phan_loai(cau_hoi, ngu_canh)
        nhan = self._goi(self._model_phan_loai, he_thong, nguoi_dung, _MAX_TOKENS_PHAN_LOAI)
        nhan = nhan.strip().strip('.').lower()
        if nhan not in CAC_NHAN_YDINH:
            for ung_vien in CAC_NHAN_YDINH:
                if ung_vien in nhan:
                    return ung_vien  # type: ignore[return-value]
            return 'khac'
        return nhan  # type: ignore[return-value]

    def dien_giai(self, cau_hoi: str, cac_doan: List[KetQuaTim], ngu_canh: NguCanh) -> str:
        # Mô hình theo VAI (congcu/cau_hinh_ai.py): lõi đặt ngu_canh.mo_hinh khi
        # cần "chuyên gia"; còn lại là vai "trả lời" trong Cài đặt website.
        he_thong, nguoi_dung = dung_prompt_dien_giai(cau_hoi, cac_doan, ngu_canh)
        model = ((getattr(ngu_canh, 'mo_hinh', '') or '').strip()
                 or _mo_hinh_cai_dat('tra_loi', getattr(ngu_canh, 'khong_gian', 'tradingauto'))
                 or self._model_dien_giai)
        anh = list(getattr(ngu_canh, 'anh', None) or [])
        if anh:
            # Ảnh đi CÙNG lượt trả lời (cùng lịch sử, tài liệu) — hiểu trong mạch hội
            # thoại. Gửi base64: đo 25/09/2026 claude-opus-5 qua cổng KHÔNG thấy ảnh gửi
            # bằng link. Câu dẫn hệ thống giữ nguyên (không chèn gì) để phần đầu cache được.
            nguoi_dung = [{'type': 'text', 'text': nguoi_dung + huong_dan_anh(len(anh))}] + [
                {'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,' + b}} for b in anh[:4]]
        return self._goi(model, he_thong, nguoi_dung, _MAX_TOKENS_DIEN_GIAI)  # type: ignore[arg-type]

    def tom_tat(self, cau_dan_he_thong: str, cau_nguoi_dung: str, mo_hinh: Optional[str] = None) -> str:
        # Chạy nền. Nơi gọi truyền mô hình theo vai (tổng hợp / việc vặt);
        # không truyền thì vai "tổng hợp", rồi LLM_MODEL_TOM_TAT, rồi model phân loại.
        model = ((mo_hinh or '').strip() or _mo_hinh_cai_dat('tong_hop')
                 or os.environ.get('LLM_MODEL_TOM_TAT', '').strip() or self._model_phan_loai)
        return self._goi(model, cau_dan_he_thong, cau_nguoi_dung, _MAX_TOKENS_TOM_TAT)

    # ---------------- GỌI CÓ CÔNG CỤ (agent tư vấn, 29/09/2026) ----------------
    # Đo 28/09/2026 qua cổng hhtechapi.net: deepseek-v4-flash, claude-opus-5, gpt-6-sol đều trả
    # `tool_calls` chuẩn OpenAI (~3 giây). nao/agent.py dùng hàm này cho vòng agent.

    def _hoi_thoai_mot_lan(self, model_gui: str, cac_tin: list, cong_cu: Optional[list], max_tokens: int) -> dict:
        import httpx
        than_bai = {'model': model_gui, 'max_tokens': max_tokens, 'messages': cac_tin}
        if cong_cu:
            than_bai['tools'] = cong_cu
        tieu_de = {'Authorization': f'Bearer {self._khoa}', 'Content-Type': 'application/json', 'User-Agent': _USER_AGENT}
        bat_dau = time.perf_counter()
        try:
            # Vòng agent (có công cụ, mô hình hay nghĩ ngầm) luôn chờ như mô hình mạnh: đo 29/09/2026 DeepSeek 4.1
            # Flash bị cắt ở mốc 30s của "mô hình rẻ" 11 lần / 85 lượt dù vẫn đang trả lời bình thường.
            phan_hoi = httpx.post(self._duong_dan_day_du(), json=than_bai, headers=tieu_de, timeout=_THOI_GIAN_CHO_MANH)
            phan_hoi.raise_for_status()
        except httpx.TimeoutException as loi:
            raise _LoiHaTang(f'Cổng không phản hồi sau {_THOI_GIAN_CHO_MANH}s (model={model_gui}): {loi}') from loi
        except httpx.HTTPStatusError as loi:
            ma = loi.response.status_code
            tb = f'Cổng trả lỗi HTTP {ma} (model={model_gui}): {self._an_khoa_trong_van_ban(loi.response.text)[:500]}'
            if ma >= 500 or ma in _MA_HTTP_HA_TANG_KHAC_5XX:
                raise _LoiHaTang(tb) from loi
            raise LoiNhaCungCap(tb) from loi
        except httpx.HTTPError as loi:
            raise _LoiHaTang(f'Lỗi mạng khi gọi cổng (model={model_gui}): {loi}') from loi
        try:
            du_lieu = phan_hoi.json()
            lua_chon = du_lieu['choices'][0]
            tin = lua_chon['message']
        except (ValueError, KeyError, IndexError, TypeError) as loi:
            raise LoiNhaCungCap(f'Phản hồi không đúng định dạng (model={model_gui}): {loi}') from loi
        if not (tin.get('content') or '').strip() and not tin.get('tool_calls'):
            raise _LoiHaTang(f'Cổng trả về rỗng (model={model_gui}).')
        if lua_chon.get('finish_reason') == 'length':
            _log.warning('Agent: câu trả lời bị cắt vì chạm max_tokens=%s (model=%s).', max_tokens, model_gui)
        su_dung = du_lieu.get('usage') or {}
        return {'tin': tin, 'model': du_lieu.get('model') or model_gui,
                'token_vao': su_dung.get('prompt_tokens', su_dung.get('input_tokens')) or 0,
                'token_ra': su_dung.get('completion_tokens', su_dung.get('output_tokens')) or 0,
                'ms': (time.perf_counter() - bat_dau) * 1000.0}

    def hoi_thoai(self, model_gui: str, cac_tin: list, cong_cu: Optional[list] = None,
                  max_tokens: int = _MAX_TOKENS_DIEN_GIAI) -> dict:
        """Một lượt chat/completions với danh sách tin (system/user/assistant/tool) và công cụ.
        Trả {'tin': message dict, 'model', 'token_vao', 'token_ra', 'ms'}. Như _goi(): CHỈ sang mô hình
        dự phòng khi hạ tầng hỏng (không bao giờ vì nội dung chưa vừa ý)."""
        du_phong = ((_mo_hinh_cai_dat('du_phong_manh') if _la_manh(model_gui) else '')
                    or _mo_hinh_cai_dat('du_phong') or self._model_du_phong)
        try:
            kq = self._hoi_thoai_mot_lan(model_gui, cac_tin, cong_cu, max_tokens)
        except _LoiHaTang as loi_chinh:
            if not du_phong or du_phong == model_gui:
                raise
            _log.warning('Agent: model %s hỏng hạ tầng, sang dự phòng %s. Lỗi: %s', model_gui, du_phong, loi_chinh)
            kq = self._hoi_thoai_mot_lan(du_phong, cac_tin, cong_cu, max_tokens)
        self._rieng.model_da_gui = kq['model']
        self._rieng.model_thuc_te = kq['model']
        self._rieng.token_vao = kq['token_vao']
        self._rieng.token_ra = kq['token_ra']
        self._rieng.thoi_gian_ms = kq['ms']
        return kq


if __name__ == '__main__':
    # =====================================================================
    # TỰ KIỂM — hoàn toàn KHÔNG gọi mạng thật.
    # =====================================================================

    print('=== 1) chuan_hoa_goc() — bốn dạng LLM_GOC phải ra cùng một URL ===')
    _CAC_DANG_GOC = (
        'https://hhtechapi.net',
        'https://hhtechapi.net/',
        'https://hhtechapi.net/v1',
        'https://hhtechapi.net/v1/',
    )
    _KET_QUA_CHUAN_HOA = set()
    for _d in _CAC_DANG_GOC:
        _cq = chuan_hoa_goc(_d)
        _KET_QUA_CHUAN_HOA.add(_cq)
        print(f'  {_d!r:32} -> {_cq!r}')
    assert len(_KET_QUA_CHUAN_HOA) == 1, f'LỖI: các dạng base_url không ra cùng kết quả: {_KET_QUA_CHUAN_HOA}'
    (_GOC_CHUAN,) = _KET_QUA_CHUAN_HOA
    _URL_GOI_THAT = f'{_GOC_CHUAN}/v1/chat/completions'
    print(f'  OK: cả bốn dạng chuẩn hoá về {_GOC_CHUAN!r} -> URL gọi = {_URL_GOI_THAT!r}')
    assert _URL_GOI_THAT == 'https://hhtechapi.net/v1/chat/completions'

    print('\n=== 2) che_khoa() ===')
    _CAC_KHOA_THU = (
        ('sk-4123456789abcdef4cc6', 'sk-412…4cc6'),
        ('sk-short', '…'),
        ('', '…'),
        (None, '…'),
    )
    for _khoa, _ky_vong in _CAC_KHOA_THU:
        _thuc_te = che_khoa(_khoa)
        _co = 'OK ' if _thuc_te == _ky_vong else 'SAI'
        print(f'  [{_co}] che_khoa({_khoa!r}) = {_thuc_te!r} (kỳ vọng {_ky_vong!r})')
        assert _thuc_te == _ky_vong, f'LỖI che_khoa({_khoa!r}): được {_thuc_te!r}, kỳ vọng {_ky_vong!r}'

    print('\n=== 3) Thiếu biến môi trường bắt buộc -> LoiNhaCungCap nêu đúng tên biến ===')
    _CAC_BIEN = ('LLM_GOC', 'LLM_KHOA', 'LLM_MODEL_PHAN_LOAI', 'LLM_MODEL_DIEN_GIAI', 'LLM_MODEL_DU_PHONG')
    _LUU_CU = {ten: os.environ.pop(ten, None) for ten in _CAC_BIEN}
    try:
        try:
            NhaCungCapTuongThichOpenAI()
            print('  SAI: lẽ ra phải ném LoiNhaCungCap')
            raise SystemExit(1)
        except LoiNhaCungCap as loi:
            print('  OK, ném đúng LoiNhaCungCap:')
            print('   ', loi)
            for ten in ('LLM_GOC', 'LLM_KHOA', 'LLM_MODEL_DIEN_GIAI'):
                assert ten in str(loi), f'LỖI: thông báo lỗi không nêu tên biến thiếu {ten}'
            assert 'LLM_MODEL_PHAN_LOAI' not in str(loi), (
                'LỖI: LLM_MODEL_PHAN_LOAI không còn bắt buộc, không được liệt vào danh sách thiếu'
            )

        print('\n=== 4) Thiếu LLM_MODEL_PHAN_LOAI (còn đủ 3 biến kia) -> KHÔNG được ném lỗi ===')
        os.environ['LLM_GOC'] = 'https://hhtechapi.net/v1'
        os.environ['LLM_KHOA'] = 'sk-thu-nghiem-khong-goi-that-4cc6'
        os.environ['LLM_MODEL_DIEN_GIAI'] = 'deepseek-v4-flash'
        os.environ.pop('LLM_MODEL_PHAN_LOAI', None)
        os.environ.pop('LLM_MODEL_DU_PHONG', None)
        _ncc = NhaCungCapTuongThichOpenAI()
        print(
            f'  OK: khởi tạo được. model_phan_loai rơi về = {_ncc._model_phan_loai!r} '
            f'(phải bằng model_dien_giai = {_ncc._model_dien_giai!r})'
        )
        assert _ncc._model_phan_loai == _ncc._model_dien_giai
        print(f'  goc chuẩn hoá = {_ncc._goc!r}, url gọi thật sẽ là {_ncc._duong_dan_day_du()!r}')
        assert _ncc._duong_dan_day_du() == 'https://hhtechapi.net/v1/chat/completions'
        print(f'  model dự phòng (không đặt) = {_ncc._model_du_phong!r} (phải là None)')
        assert _ncc._model_du_phong is None
        print(f'  che_khoa() dùng cho khoá vừa đặt: {che_khoa(_ncc._khoa)!r}')
    finally:
        for _ten, _gia_tri in _LUU_CU.items():
            if _gia_tri is not None:
                os.environ[_ten] = _gia_tri
            else:
                os.environ.pop(_ten, None)

    print('\nTỰ KIỂM tuongthich_openai.py HOÀN TẤT — không gọi mạng thật.')
