docs/adr/0005-multi-llm-provider-support.mdXem bản gốc Bản dịch đầy đủ theo đúng cấu trúc tài liệu; code, liên kết và định danh kỹ thuật được giữ nguyên.
0005. Hỗ trợ nhiều nhà cung cấp LLM - phụ trợ suy luận có thể lựa chọn (Gemini / OpenRouter / Modal)
Trạng thái: Đã chấp nhận Ngày: 2026-06-26
Bối cảnh
Cột mốc 1 đã kết nối vòng lặp agent với một chương trình phụ trợ duy nhất — Gemini thông qua google-gla Đường dẫn khóa API - nhưng
cố tình xây dựng agent/factory.py dưới dạng đường nối trao đổi nhà cung cấp (ADR-0002
§1: "Hoán đổi M2 trong OpenRouter/Modal sau cùng một nhà máy"). Đang chạy decode do đó yêu cầu phải trả tiền
Chìa khóa Song Tử. Để cho phép mọi người chạy nó miễn phí, chúng tôi thêm hai phần phụ trợ suy luận nữa đằng sau cùng một đường nối:
- OpenRouter — một cổng tương thích với OpenAI với
:freetùy chọn mô hình. - Điểm cuối tự động theo phương thức — Tương thích với OpenAI
/v1điểm cuối phục vụ các mô hình nguồn mở trên Modal's $30 tín dụng miễn phí. Việc lựa chọn mô hình, sự cân bằng GPU/phân phối và thiết lập điểm cuối được ghi lại trong danh mục đồng hành02_modal_endpoints.md(ảnh chụp nhanh ngày 26-06-2026) — bản ghi ADR này quyết định kết nối và tham chiếu tệp đó cho mô hình nào và cách tạo điểm cuối.
Đường nối là kiến trúc duy nhất ADR-0002 còn bỏ ngỏ ở đây; vòng lặp, công cụ, quyền, bộ nhớ và
TUI không bị ảnh hưởng. ADR này ghi lại các lựa chọn liên quan dưới dạng quyết định tính năng một (không phải một quyết định cho mỗi nhiệm vụ):
bộ chọn rõ ràng, cấu hình theo nhà cung cấp, ba cấu trúc mô hình, tùy chọn của Modal
xác thực token proxy, mặc định được gửi + cảnh báo khi gọi công cụ và các mục tiêu không phải. Bốn nhiệm vụ
sự cố sống ở tasks/ (đặc điểm multi-provider-gateway, nhiệm vụ 037-040). Tất cả
hệ thống dây điện đã được xác minh dựa trên ** pydantic-ai 1.107 / openai 2.43 ** đã cài đặt ** và OpenRouter trực tiếp
danh sách model trong quá trình chải chuốt nên không bị kiện tụng lại ở hạ lưu.
Quyết định
-
Rõ ràng
LLM_PROVIDERbộ chọn - không tự động phát hiện. Mộtllm_provider: Trường Literal["gemini","openrouter","modal"] = "gemini"trên phần cài đặt chọn phần phụ trợ; pydantic xác nhận giá trị khi tải. Mặc địnhgeminitương thích ngược — hiện có.envcái đó chỉ đặtGEMINI_API_KEYtiếp tục làm việc mà không có dòng mới. “Chạy miễn phí” được phục vụ bởi ghi lại việc chọn tham gia, không phải bằng cách lật ngược mặc định. Chúng tôi từ chối việc tự động phát hiện nhà cung cấp từ đó các khóa tình cờ xuất hiện: nó ẩn ý, đáng ngạc nhiên và mơ hồ khi một số khóa được đặt. -
Các trường cấu hình theo nhà cung cấp, trên một trình đọc cài đặt. OpenRouter:
openrouter_api_key(SecretStr),openrouter_model. Modal điểm cuối (tên theo 02_modal_endpoints.md §6):modal_endpoint_url(không có mặc định - đầu ra triển khai cho mỗi người dùng; được sử dụng làmbase_url = f"{...}/v1"),modal_endpoint_model,modal_proxy_token_id(SecretStr, cáiModal-Key: wk-...tiêu đề) vàmodal_proxy_token_secret(SecretStr, cáiModal-Secret: ws-...tiêu đề). Các điểm cuối phương thức này các vars khác biệt với các vars hiện cóMODAL_TOKEN_ID/MODAL_TOKEN_SECRETtài khoản token (modal token set, đối với CLI/sandbox — 02_modal_endpoints.md §5.1): việc nạp quá nhiều chúng sẽ kết hợp hai phạm vi thông tin xác thực không liên quan. Tất cả các vars mới được nhân đôi trong.env.example; không có gì đọcos.environtrong các trang web cuộc gọi. -
Một
_build_model()Nhà cung cấp đường may tại nhà máy.build_agent()đại biểu xây dựng mô hình đến một tư nhân nhỏ_build_model()đó là nhánh trênsettings.llm_provider; phần còn lại củabuild_agent()—deps_type,output_type=[str, DeferredToolRequests], hướng dẫn,register_tools, và ba@agent.instructionshooks - không phụ thuộc vào nhà cung cấp và không thay đổi. A giá trị vượt qua ba nhánh tăngValueError(phòng thủ;Literalchặn nó ngược dòng). -
Ba cấu trúc mô hình (được xác minh dựa trên SDK đã cài đặt):
- song tử (không thay đổi):
GoogleModel(settings.gemini_model, provider=GoogleProvider(api_key=...))— google-gla, không bao giờvertexai=(filterwarnings=["error"]). - openrouter:
OpenAIChatModel(settings.openrouter_model, nhà cung cấp=OpenRouterProvider(api_key=...))(model.system == "openrouter"). - phương thức:
OpenAIChatModel(settings.modal_endpoint_model, nhà cung cấp=OpenAIProvider(openai_client=AsyncOpenAI(...)))— một ứng dụng khách tùy chỉnh (xem §5).
- song tử (không thay đổi):
-
Xác thực token proxy theo phương thức là TÙY CHỌN — hỗ trợ
--unauthenticatedđiểm cuối. Điểm cuối tự động theo phương thức yêu cầu xác thực theo mặc định, thông qua cặp tiêu đềModal-Key(Ư-...) +Modal-Secret(v-...) từmodal workspace proxy-tokens create— không phải OpenAIAuthorization: Bearerkế hoạch (02_modal_endpoints.md §5.3, §6.3). Nhưng điểm cuối của nhà phát triển được tạo bằng--unauthenticatedkhông cần tiêu đề. Vì vậy, nhánh phương thức luôn xây dựng một tùy chỉnhAsyncOpenAIkhách hàng (đối với tùy chỉnhbase_url) và:- cả hai token proxy được đặt →
AsyncOpenAI(base_url=f"{modal_endpoint_url}/v1", api_key=<modal_proxy_token_secret>, default_headers={"Modal-Key": <modal_proxy_token_id>, "Modal-Bí mật": <modal_proxy_token_secret>}). - không đặt →
AsyncOpenAI(base_url=f"{modal_endpoint_url}/v1", api_key="EMPTY")không có phương thức tiêu đề (SDK openai yêu cầu không trốngapi_key, vì vậy một trình giữ chỗ được thông qua). Máy khách một kênh sẽ không xác thực điểm cuối được xác thực. Bất biến cả hai hoặc không được thực thi ở bộ bảo vệ cli (§6): chính xác một bộ token là cấu hình sai thân thiện.
- cả hai token proxy được đặt →
-
Bảo vệ khởi động tổng quát. Nhiệm vụ-004
GEMINI_API_KEY-chỉ canh gáccli.pykhái quát hóa thành xác thực cấu hình yêu cầu của nhà cung cấp ** đã chọn **, in MỘT dòng stderr thân thiện và thoát khác không (không có dấu vết), phản ánh phong cách và vị trí của người bảo vệ hiện có (trước agent/chế độ xác thực và trước khi agent được xây dựng): gemini →GEMINI_API_KEY; bộ định tuyến mở →OPENROUTER_API_KEY; phương thức →MODAL_ENDPOINT_URL+MODAL_ENDPOINT_MODELchỉ (token proxy không bắt buộc), cộng với kiểm tra cả hai hoặc không gắn cờ chính xác một bộ token proxy. -
Mặc định đã vận chuyển, cảnh báo khi gọi công cụ và không phải mục tiêu.
openrouter_modelmặc định là một hiện tại mô hình miễn phí nổi tiếng hỗ trợ gọi công cụ + phát trực tuyến (đã xác minhqwen/qwen3-coder:freechống lại danh sách OpenRouter trực tiếp vào ngày 26-06-2026; tài liệu thay thếmeta-llama/llama-3.3-70b-instruct:free).modal_endpoint_modelmặc định làopenai/gpt-oss-120b— Lựa chọn phù hợp nhất của 02_modal_endpoints.md (gọi công cụ OpenAI gốc = tiêu chí khai thác số 1, đơn B200); tài liệu thay thếQwen/Qwen3.6-35B-A3B-FP8(nhà phát triển giá rẻ, 1×H100) vàzai-org/GLM-5.2-FP8(tối đa). Vòng lặp agent cần một mô hình có khả năng gọi công cụ + phát trực tuyến; tài liệu có dòng chữ "nếu bạn trao đổi các mô hình, hãy chọn một mô hình có khả năng sử dụng công cụ" cho cả OpenRouter và Modal. Không có mục tiêu (điều đơn giản nhất hoạt động): không có tính năng tự động phát hiện nhà cung cấp và không có dự phòng của nhà cung cấp /FallbackModel- một nhà cung cấp bị định cấu hình sai sẽ thất bại nhanh chóng ở mức bảo vệ hoặc yêu cầu đầu tiên, nó không âm thầm quay trở lại.
Sơ đồ
Nhà cung cấp đường may sau quyết định này. Bộ chọn và bộ bảo vệ đọc các cài đặt đơn lẻ; _build_model()
trả về một trong ba mô hình; máy khách phương thức thích ứng với xác thực so với --unauthenticated; vòng lặp
là nhà cung cấp bất khả tri.
flowchart TB
user(["User: LLM_PROVIDER + secrets in .env"])
subgraph cfg["config/settings.py (singleton)"]
sel["llm_provider: Literal[gemini|openrouter|modal]<br/>+ per-provider fields & secrets"]
end
guard["cli.py startup guard<br/>(required config for the selected provider;<br/>modal: URL+MODEL only, proxy tokens both-or-neither<br/>→ 1 friendly line + non-zero exit, no traceback)"]
subgraph factory["agent/factory.py"]
build["build_agent()<br/>(deps_type · output_type=[str, DeferredToolRequests]<br/>· instructions hooks · register_tools — provider-agnostic)"]
seam{"_build_model()<br/>Provider Seam<br/>branch on llm_provider"}
end
gem["gemini:<br/>GoogleModel + GoogleProvider<br/>(google-gla, system=google)"]
orouter["openrouter:<br/>OpenAIChatModel + OpenRouterProvider<br/>(system=openrouter, :free model)"]
modal["modal:<br/>OpenAIChatModel + OpenAIProvider<br/>custom AsyncOpenAI(base_url=.../v1)<br/>auth → headers Modal-Key + Modal-Secret<br/>--unauthenticated → api_key=EMPTY, no headers"]
loop["Agent loop (Pydantic AI ReAct)<br/>unchanged — ADR-0002"]
user --> cfg
sel --> guard
guard -->|config OK| build
build --> seam
seam -->|gemini default| gem
seam -->|openrouter| orouter
seam -->|modal| modal
gem --> loop
orouter --> loop
modal --> loop
classDef gemini fill:#2e7d32,stroke:#1b5e20,color:#ffffff;
classDef router fill:#1565c0,stroke:#0d47a1,color:#ffffff;
classDef modalc fill:#e65100,stroke:#bf360c,color:#ffffff;
classDef seamc fill:#6a1b9a,stroke:#4a148c,color:#ffffff;
classDef plain fill:#eceff1,stroke:#607d8b,color:#102027;
class gem gemini;
class orouter router;
class modal modalc;
class seam,build seamc;
class user,cfg,sel,guard,loop plain;
hậu quả
- Miễn phí
decodetrở nên khả thi mà không cần thay đổi mã cho mỗi người dùng — hãy chọn tham gia thông quaLLM_PROVIDER+ cấu hình của nhà cung cấp đó; mặc định gemini giữ mọi thứ hiện có.envlàm việc không bị ảnh hưởng. - ** Đường nối trao đổi nhà cung cấp ADR-0002 §1 được trì hoãn hiện đã được thực hiện ** dưới dạng
_build_model(); phụ trợ trong tương lai thêm một nhánh + trường cài đặt của chúng + một trường hợp bảo vệ, không có gì khác. - Các chương trình phụ trợ tương thích với OpenAI sử dụng một lớp mô hình (
OpenAIChatModel); chỉ Modal mới cần một tùy chỉnh khách hàng, bị cô lập với chi nhánh của nó vì phong tụcbase_urlvà tiêu đề kép tùy chọn xác thực token proxy. --unauthenticatedĐiểm cuối phương thức hoạt động cho nhà phát triển cục bộ (không có hệ thống ống nước tiêu đề); xác thực điểm cuối nhận đượcModal-Key/Modal-Secrettiêu đề. Việc bảo vệ cả hai hoặc không ngăn cản trạng thái được cấu hình một nửa sẽ là 401 ở yêu cầu đầu tiên.- Id mô hình OpenRouter miễn phí bị xáo trộn — mặc định được vận chuyển được xác minh khi chuẩn bị và được xác minh lại lúc
thực hiện; id cũ xuất hiện dưới dạng lỗi yêu cầu đầu tiên, không phải lỗi thầm lặng (không có dự phòng).
Mặc định phương thức
openai/gpt-oss-120bđược ghim vào vị trí phù hợp nhất của danh mục; 02_modal_endpoints.md là nơi kinh điển để chọn một người thay thế. - Hai phạm vi thông tin xác thực phương thức hiện cùng tồn tại (token tài khoản và token proxy điểm cuối);
.env.example, README và 02_modal_endpoints.md §6 phải tách biệt chúng rõ ràng để tránh nhầm lẫn cho người dùng. - Không có dự phòng là một lựa chọn đơn giản có chủ ý — một nhà cung cấp bị định cấu hình sai sẽ nhanh chóng thất bại (bảo vệ hoặc yêu cầu đầu tiên) thay vì che giấu cấu hình sai bằng cách chuyển đổi phần phụ trợ; xem lại nếu có nhiều mô hình định tuyến là muốn sau này.
- CI vẫn ngoại tuyến: việc xây dựng mô hình gặp sự cố không có cuộc gọi mạng, do đó, nhà máy đã kiểm tra tham số hóa
khẳng định mô hình loại + hình dạng máy khách (base_url, tiêu đề/giữ chỗ api_key) cho mỗi nhà cung cấp với
mocker.patchcài đặt ed; Xác thực nhà cung cấp trực tiếp là mối quan tâm thủ công của e2e.