fastapi-docs-plus 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ from .config import DocsPlusConfig
2
+ from .docs_plus import setup_docs_plus
3
+ from .pre_request import PreRequestContext
4
+
5
+ __all__ = [
6
+ "DocsPlusConfig",
7
+ "PreRequestContext",
8
+ "setup_docs_plus",
9
+ ]
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from collections import deque
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from openai import AsyncOpenAI
11
+ from jsonschema import Draft202012Validator
12
+
13
+ from .config import DocsPlusConfig
14
+ from .i18n import Language, language_instruction, translate
15
+
16
+ _LOCATIONS = ("path", "query", "header", "cookie")
17
+ _FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$")
18
+
19
+ SYSTEM_PROMPT = """You generate realistic API request data from an OpenAPI operation specification.
20
+
21
+ Output rules:
22
+ 1. Return one JSON object with only these keys: path, query, header, cookie, body.
23
+ 2. path/query/header/cookie map declared parameter names to scalar values; use {} for empty locations. Never invent parameters.
24
+ 3. body must strictly match requestBody.schema; use null when there is no requestBody.
25
+ 4. Obey enum, const, pattern, format, minimum/maximum, minLength/maxLength, minItems, required and all other schema constraints.
26
+ 5. Use description / examples / businessHint for business meaning, but write free-form natural-language values such as remark, comments, name, address and keyword in the selected output language, even when descriptions, examples or businessHint are Chinese or use another language.
27
+ 6. Never translate JSON keys, enum/const values, protocol identifiers, or fixed business codes/values. Language selection must not violate schema constraints, including country-specific phone formats.
28
+ 7. Use ISO 8601 timestamps, reasonable monetary amounts and realistic, correctly formatted IDs.
29
+ 8. Do not use placeholders such as "string", "foo", "test", 0 or empty strings unless the schema requires them. Include optional fields when useful.
30
+ 9. Do not output explanations, comments or Markdown fences.
31
+ 10. When generation history is provided, make new values clearly different from each previous result while obeying the schema and the selected output language. These language rules also apply to correction retries."""
32
+
33
+
34
+ class AIFillError(RuntimeError):
35
+ """Raised when AI parameter generation fails for a known reason.
36
+
37
+ The error carries a localizable message key so callers can present
38
+ user-facing messages in the appropriate language.
39
+ """
40
+
41
+ def __init__(self, message_key: str, **params: Any) -> None:
42
+ self.message_key = message_key
43
+ self.params = params
44
+ super().__init__(self.localize())
45
+
46
+ def localize(self, language: Language = "en") -> str:
47
+ """Return the error message localized to *language*."""
48
+ params = {
49
+ key: value.localize(language) if isinstance(value, AIFillError) else value
50
+ for key, value in self.params.items()
51
+ }
52
+ return translate(self.message_key, language, **params)
53
+
54
+
55
+ # ------------------------------------------------------------------ Cache
56
+
57
+ @dataclass
58
+ class _OpCache:
59
+ """Per-operation queue of cached generation results.
60
+
61
+ The cursor advances monotonically then wraps via modulo so that
62
+ evicting the oldest entry does not disturb the round-robin order.
63
+ """
64
+
65
+ schema_fingerprint: str
66
+ items: deque[dict] = field(default_factory=deque)
67
+ cursor: int = 0
68
+
69
+
70
+ _CACHES: dict[tuple[Language, str], _OpCache] = {}
71
+
72
+
73
+ def _op_key(path: str, method: str) -> str:
74
+ return f"{method.upper()} {path}"
75
+
76
+
77
+ def _fingerprint(op_spec: dict) -> str:
78
+ return hashlib.sha256(json.dumps(op_spec, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
79
+
80
+
81
+ def next_fill(path: str, method: str, language: Language = "en") -> dict | None:
82
+ """Retrieve the next cached result for a given operation in round-robin order.
83
+
84
+ Returns ``None`` when the cache is empty.
85
+ """
86
+ entry = _CACHES.get((language, _op_key(path, method)))
87
+ if entry is None or not entry.items:
88
+ return None
89
+ index = entry.cursor % len(entry.items)
90
+ entry.cursor += 1
91
+ return {**entry.items[index], "cacheIndex": index, "cacheCount": len(entry.items)}
92
+
93
+
94
+ def cache_counts(language: Language = "en") -> dict[str, int]:
95
+ """Return a mapping of ``"METHOD path"`` to cached-item count for *language*."""
96
+ return {
97
+ key: len(entry.items)
98
+ for (entry_language, key), entry in _CACHES.items()
99
+ if entry_language == language and entry.items
100
+ }
101
+
102
+
103
+ # ------------------------------------------------------------------ Generation
104
+
105
+
106
+ def _build_client(config: DocsPlusConfig):
107
+ if not config.llm_api_key:
108
+ raise AIFillError("missing_key")
109
+ return AsyncOpenAI(api_key=config.llm_api_key, base_url=config.llm_base_url)
110
+
111
+
112
+ def _normalize(data: Any) -> dict:
113
+ if not isinstance(data, dict):
114
+ raise AIFillError("non_object")
115
+ result: dict[str, Any] = {}
116
+ for key in _LOCATIONS:
117
+ value = data.get(key)
118
+ result[key] = value if isinstance(value, dict) else {}
119
+ result["body"] = data.get("body")
120
+ return result
121
+
122
+
123
+ def _prune(data: dict, op_spec: dict) -> dict:
124
+ """Remove parameters the model invented that are not declared in the schema."""
125
+ declared: dict[str, set[str]] = {location: set() for location in _LOCATIONS}
126
+ for param in op_spec.get("parameters", []):
127
+ location = param.get("in")
128
+ if location in declared and param.get("name"):
129
+ declared[location].add(param["name"])
130
+ for location in _LOCATIONS:
131
+ data[location] = {k: v for k, v in data[location].items() if k in declared[location]}
132
+ if not op_spec.get("requestBody"):
133
+ data["body"] = None
134
+ return data
135
+
136
+
137
+ def _validate(data: dict, op_spec: dict) -> str | AIFillError | None:
138
+ body_schema = (op_spec.get("requestBody") or {}).get("schema")
139
+ if not body_schema:
140
+ return None
141
+ if data.get("body") is None:
142
+ return AIFillError("body_required") if op_spec["requestBody"].get("required") else None
143
+ errors = sorted(Draft202012Validator(body_schema).iter_errors(data["body"]), key=lambda e: list(e.path))
144
+ if not errors:
145
+ return None
146
+ return "; ".join(f"{'/'.join(map(str, e.path)) or '<root>'}: {e.message}" for e in errors[:5])
147
+
148
+
149
+ async def _complete(client, config: DocsPlusConfig, messages: list[dict], temperature: float) -> dict:
150
+ response = await client.chat.completions.create(
151
+ model=config.llm_model,
152
+ messages=messages,
153
+ temperature=temperature,
154
+ response_format={"type": "json_object"},
155
+ timeout=config.llm_timeout,
156
+ )
157
+ if not response.choices:
158
+ raise AIFillError("empty_response")
159
+ raw = (response.choices[0].message.content or "").strip()
160
+ if not raw:
161
+ raise AIFillError("empty_response")
162
+ try:
163
+ return _normalize(json.loads(_FENCE.sub("", raw)))
164
+ except json.JSONDecodeError as exc:
165
+ raise AIFillError("invalid_json") from exc
166
+
167
+
168
+ async def generate_and_cache(
169
+ op_spec: dict, config: DocsPlusConfig, *, path: str, method: str, language: Language = "en"
170
+ ) -> int:
171
+ """Generate parameter values via LLM and cache them on validation success.
172
+
173
+ Only results that pass ``jsonschema`` validation against the
174
+ operation's ``requestBody`` are added to the in-memory cache.
175
+ Returns the current number of cached items for this operation.
176
+
177
+ Args:
178
+ op_spec: Self-contained operation spec from
179
+ :func:`build_operation_spec`.
180
+ config: Global ``DocsPlusConfig``.
181
+ path: URL path for cache keying.
182
+ method: HTTP method for cache keying.
183
+ language: Output language for generated values.
184
+
185
+ Returns:
186
+ Number of cached items after insertion.
187
+
188
+ Raises:
189
+ AIFillError: On configuration issues, schema size overflow,
190
+ empty LLM responses, or repeated validation failure.
191
+ """
192
+ key = (language, _op_key(path, method))
193
+ fingerprint = _fingerprint(op_spec)
194
+ entry = _CACHES.get(key)
195
+ if entry is None or entry.schema_fingerprint != fingerprint:
196
+ # Schema changed — discard old cache (including history used as context)
197
+ entry = _OpCache(schema_fingerprint=fingerprint)
198
+ _CACHES[key] = entry
199
+
200
+ payload = json.dumps(op_spec, ensure_ascii=False, sort_keys=True)
201
+ history = list(entry.items)
202
+ if history:
203
+ compact = json.dumps(history, ensure_ascii=False, separators=(",", ":"))
204
+ payload += f"\n\nGeneration history (make new values clearly different):\n{compact}"
205
+ if len(payload) > config.max_schema_chars:
206
+ raise AIFillError("schema_too_large", size=len(payload), limit=config.max_schema_chars)
207
+
208
+ # More history → more diversity needed
209
+ temperature = min(1.0, config.llm_temperature + 0.1 * min(len(history), 5))
210
+ instruction = language_instruction(language)
211
+ messages: list[dict] = [
212
+ {"role": "system", "content": f"{SYSTEM_PROMPT}\n\n{instruction}"},
213
+ {"role": "user", "content": payload},
214
+ ]
215
+
216
+ client = _build_client(config)
217
+ data = _prune(await _complete(client, config, messages, temperature), op_spec)
218
+
219
+ problem = _validate(data, op_spec)
220
+ if problem:
221
+ messages = [
222
+ *messages,
223
+ {"role": "assistant", "content": json.dumps(data, ensure_ascii=False)},
224
+ {
225
+ "role": "user",
226
+ "content": f"The previous output failed schema validation: {problem}\n"
227
+ f"Correct it and return the complete JSON object. {instruction}",
228
+ },
229
+ ]
230
+ retried = _prune(await _complete(client, config, messages, temperature), op_spec)
231
+ second_problem = _validate(retried, op_spec)
232
+ if second_problem:
233
+ raise AIFillError("validation_failed", problem=second_problem)
234
+ data = retried
235
+
236
+ entry.items.append(data)
237
+ while len(entry.items) > max(1, config.ai_cache_max_size):
238
+ entry.items.popleft()
239
+ return len(entry.items)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ def _env(*names: str, default: str | None = None) -> str | None:
8
+ """Read the first non-empty environment variable from *names."""
9
+ for name in names:
10
+ value = os.getenv(name)
11
+ if value:
12
+ return value
13
+ return default
14
+
15
+
16
+ _DEFAULT_SWAGGER_UI_PARAMETERS: dict = {
17
+ "persistAuthorization": True,
18
+ "displayRequestDuration": True,
19
+ "docExpansion": "list",
20
+ "showExtensions": True,
21
+ "showCommonExtensions": True,
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class DocsPlusConfig:
27
+ """Configuration for fastapi-docs-plus.
28
+
29
+ Controls Swagger UI rendering, AI parameter generation, and
30
+ pre-request hook behaviour.
31
+
32
+ Attributes:
33
+ docs_url: Path for the documentation page (default ``"/docs"``).
34
+ api_prefix: Prefix for internal API endpoints and static assets
35
+ (default ``"/_docs"``).
36
+ openapi_url: URL where Swagger UI fetches the OpenAPI spec
37
+ (default ``"/openapi.json"``).
38
+ swagger_ui_version: Swagger UI version used to build CDN URLs
39
+ (default ``"5.17.14"``).
40
+ swagger_ui_js_url: Override for the Swagger UI JS bundle URL.
41
+ When ``None`` the CDN URL is used.
42
+ swagger_ui_css_url: Override for the Swagger UI CSS URL.
43
+ When ``None`` the CDN URL is used.
44
+ swagger_ui_js_integrity: Subresource Integrity hash for the JS
45
+ bundle. When set, an ``integrity`` attribute is emitted.
46
+ swagger_ui_css_integrity: Subresource Integrity hash for the
47
+ CSS. When set, an ``integrity`` attribute is emitted.
48
+ swagger_ui_parameters: Extra parameters passed to the
49
+ ``SwaggerUIBundle`` constructor. Merged key-wise over the
50
+ defaults, so only the keys you provide are overridden and
51
+ the rest keep their default values. Defaults enable
52
+ ``persistAuthorization``, ``displayRequestDuration``,
53
+ ``docExpansion="list"``, ``showExtensions`` and
54
+ ``showCommonExtensions``.
55
+ identities: List of identity labels shown in the top-bar
56
+ dropdown. An empty list hides the dropdown entirely.
57
+ llm_model: LLM model identifier (default ``"gpt-4o-mini"``).
58
+ llm_base_url: Base URL for the OpenAI-compatible API. Reads
59
+ ``DOCS_PLUS_LLM_BASE_URL`` or ``OPENAI_BASE_URL``.
60
+ llm_api_key: API key for the LLM service. Reads
61
+ ``DOCS_PLUS_LLM_API_KEY`` or ``OPENAI_API_KEY``.
62
+ llm_temperature: Temperature for LLM calls. Automatically
63
+ increased by ``0.1`` per cached history entry, capped at
64
+ ``1.0`` (default ``0.3``).
65
+ llm_timeout: Timeout in seconds for each LLM call
66
+ (default ``60.0``).
67
+ max_schema_depth: Maximum depth for inlining ``$ref`` schemas
68
+ (default ``4``).
69
+ max_schema_chars: Maximum total characters for the schema plus
70
+ cached history. Beyond this the request is rejected without
71
+ calling the LLM (default ``60_000``).
72
+ ai_cache_max_size: Maximum number of validated AI-generated
73
+ results kept per operation in the in-memory cache. Oldest
74
+ entries are evicted first (default ``5``).
75
+ """
76
+
77
+ docs_url: str = "/docs"
78
+ api_prefix: str = "/_docs"
79
+ openapi_url: str = "/openapi.json"
80
+
81
+ swagger_ui_version: str = "5.17.14"
82
+ swagger_ui_js_url: str | None = None
83
+ swagger_ui_css_url: str | None = None
84
+ swagger_ui_js_integrity: str | None = None
85
+ swagger_ui_css_integrity: str | None = None
86
+ swagger_ui_parameters: dict = field(default_factory=dict)
87
+
88
+ identities: list[str] = field(default_factory=list)
89
+
90
+ llm_model: str = field(default_factory=lambda: _env("DOCS_PLUS_LLM_MODEL", default="gpt-4o-mini"))
91
+ llm_base_url: str | None = field(default_factory=lambda: _env("DOCS_PLUS_LLM_BASE_URL", "OPENAI_BASE_URL"))
92
+ llm_api_key: str | None = field(default_factory=lambda: _env("DOCS_PLUS_LLM_API_KEY", "OPENAI_API_KEY"))
93
+ llm_temperature: float = 0.3
94
+ llm_timeout: float = 60.0
95
+
96
+ max_schema_depth: int = 4
97
+ max_schema_chars: int = 60_000
98
+
99
+ ai_cache_max_size: int = 5
100
+
101
+ def __post_init__(self) -> None:
102
+ self.swagger_ui_parameters = {
103
+ **_DEFAULT_SWAGGER_UI_PARAMETERS,
104
+ **(self.swagger_ui_parameters or {}),
105
+ }
106
+
107
+ @property
108
+ def ai_enabled(self) -> bool:
109
+ return bool(self.llm_api_key)
110
+
111
+ @property
112
+ def static_url(self) -> str:
113
+ return f"{self.api_prefix}/static"
114
+
115
+ @property
116
+ def resolved_js_url(self) -> str:
117
+ base = f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{self.swagger_ui_version}"
118
+ return self.swagger_ui_js_url or f"{base}/swagger-ui-bundle.js"
119
+
120
+ @property
121
+ def resolved_css_url(self) -> str:
122
+ base = f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{self.swagger_ui_version}"
123
+ return self.swagger_ui_css_url or f"{base}/swagger-ui.css"
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from html import escape
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.responses import HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from openai import OpenAIError
12
+ from pydantic import BaseModel, Field
13
+
14
+ from .ai_fill import AIFillError, cache_counts, generate_and_cache, next_fill
15
+ from .config import DocsPlusConfig
16
+ from .i18n import Language, translate
17
+ from .pre_request import PreRequestHook, build_context, run_hooks
18
+ from .schema_utils import build_operation_spec
19
+
20
+ STATIC_DIR = Path(__file__).parent / "static"
21
+
22
+
23
+ def _asset_tag(kind: str, url: str, integrity: str | None) -> str:
24
+ attrs = f'integrity="{escape(integrity, quote=True)}" crossorigin="anonymous" ' if integrity else ""
25
+ if kind == "css":
26
+ return f'<link rel="stylesheet" {attrs}href="{escape(url, quote=True)}" />'
27
+ return f'<script {attrs}src="{escape(url, quote=True)}"></script>'
28
+
29
+
30
+ class _OpTarget(BaseModel):
31
+ path: str
32
+ method: str
33
+ language: Language = "en"
34
+
35
+
36
+ class _PreRequestPayload(BaseModel):
37
+ method: str
38
+ url: str
39
+ headers: dict[str, str] = Field(default_factory=dict)
40
+ env: dict[str, Any] = Field(default_factory=dict)
41
+
42
+
43
+ class DocsPlus:
44
+ """Enhanced interactive API docs with AI parameter filling and Python-side pre-request hooks.
45
+
46
+ Usage::
47
+
48
+ docs = DocsPlus(app, DocsPlusConfig(...))
49
+
50
+ @docs.pre_request
51
+ async def inject_auth(ctx: PreRequestContext) -> None:
52
+ ctx.headers["Authorization"] = f"Bearer {token}"
53
+ """
54
+
55
+ def __init__(self, app: FastAPI, config: DocsPlusConfig | None = None) -> None:
56
+ self.app = app
57
+ self.config = config or DocsPlusConfig()
58
+ self._hooks: list[PreRequestHook] = []
59
+ self._register_routes()
60
+
61
+ def pre_request(self, hook: PreRequestHook) -> PreRequestHook:
62
+ """Register a pre-request hook.
63
+
64
+ Multiple hooks execute in registration order and share the same
65
+ :class:`PreRequestContext`.
66
+
67
+ Args:
68
+ hook: A synchronous or asynchronous callable that receives a
69
+ :class:`PreRequestContext`.
70
+
71
+ Returns:
72
+ The same callable (allows use as a decorator).
73
+ """
74
+ self._hooks.append(hook)
75
+ return hook
76
+
77
+ def _register_routes(self) -> None:
78
+ app, config = self.app, self.config
79
+ app.mount(config.static_url, StaticFiles(directory=STATIC_DIR), name="docs_plus_static")
80
+
81
+ @app.get(config.docs_url, include_in_schema=False)
82
+ async def docs_page() -> HTMLResponse:
83
+ return HTMLResponse(self._render_page())
84
+
85
+ @app.post(f"{config.api_prefix}/api/ai/generate", include_in_schema=False)
86
+ async def ai_generate(payload: _OpTarget) -> dict:
87
+ try:
88
+ op_spec = build_operation_spec(
89
+ app.openapi(), payload.path, payload.method, max_depth=config.max_schema_depth
90
+ )
91
+ except KeyError as exc:
92
+ raise HTTPException(
93
+ status_code=404,
94
+ detail=translate(
95
+ "operation_not_found", payload.language,
96
+ method=payload.method.upper(), path=payload.path,
97
+ ),
98
+ ) from exc
99
+ try:
100
+ count = await generate_and_cache(
101
+ op_spec, config, path=payload.path, method=payload.method,
102
+ language=payload.language,
103
+ )
104
+ except AIFillError as exc:
105
+ raise HTTPException(status_code=503, detail=exc.localize(payload.language)) from exc
106
+ except OpenAIError as exc:
107
+ raise HTTPException(
108
+ status_code=503, detail=translate("service_error", payload.language)
109
+ ) from exc
110
+ return {"count": count}
111
+
112
+ @app.post(f"{config.api_prefix}/api/ai/fill", include_in_schema=False)
113
+ async def ai_fill(payload: _OpTarget) -> dict:
114
+ result = next_fill(payload.path, payload.method, payload.language)
115
+ if result is None:
116
+ raise HTTPException(status_code=409, detail=translate("empty_cache", payload.language))
117
+ return result
118
+
119
+ @app.get(f"{config.api_prefix}/api/ai/cache", include_in_schema=False)
120
+ async def ai_cache(language: Language = "en") -> dict:
121
+ return {"counts": cache_counts(language)}
122
+
123
+ @app.post(f"{config.api_prefix}/api/pre-request", include_in_schema=False)
124
+ async def pre_request(payload: _PreRequestPayload) -> dict:
125
+ ctx = build_context(
126
+ app,
127
+ method=payload.method,
128
+ url=payload.url,
129
+ headers=payload.headers,
130
+ env=payload.env,
131
+ )
132
+ await run_hooks(self._hooks, ctx)
133
+ return {
134
+ "headers": {k: str(v) for k, v in ctx.headers.items() if v is not None},
135
+ "query": {k: str(v) for k, v in ctx.query.items() if v is not None},
136
+ "operationId": ctx.operation_id,
137
+ }
138
+
139
+ def _render_page(self) -> str:
140
+ config = self.config
141
+ front_config = {
142
+ "openapiUrl": config.openapi_url,
143
+ "apiPrefix": config.api_prefix,
144
+ "identities": config.identities,
145
+ "aiEnabled": config.ai_enabled,
146
+ "hasPreRequestHook": bool(self._hooks),
147
+ "swaggerUiParameters": config.swagger_ui_parameters,
148
+ }
149
+ template = (STATIC_DIR / "docs.html").read_text(encoding="utf-8")
150
+ return (
151
+ template.replace("__STATIC_URL__", escape(config.static_url, quote=True))
152
+ .replace("__SWAGGER_CSS_TAG__", _asset_tag("css", config.resolved_css_url, config.swagger_ui_css_integrity))
153
+ .replace("__SWAGGER_JS_TAG__", _asset_tag("js", config.resolved_js_url, config.swagger_ui_js_integrity))
154
+ .replace("__CONFIG__", json.dumps(front_config, ensure_ascii=False).replace("</", "<\\/"))
155
+ )
156
+
157
+
158
+ def setup_docs_plus(app: FastAPI, config: DocsPlusConfig | None = None) -> DocsPlus:
159
+ """Create and register a :class:`DocsPlus` instance on the given FastAPI application.
160
+
161
+ This is a convenience wrapper around ``DocsPlus(app, config)``.
162
+
163
+ Args:
164
+ app: The FastAPI application instance.
165
+ config: Optional configuration. Falls back to defaults.
166
+
167
+ Returns:
168
+ The :class:`DocsPlus` instance (use it to register hooks via
169
+ :meth:`DocsPlus.pre_request`).
170
+ """
171
+ return DocsPlus(app, config)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ Language = Literal["en", "zh"]
6
+
7
+ _MESSAGES: dict[Language, dict[str, str]] = {
8
+ "en": {
9
+ "missing_key": "LLM credentials are not configured. Set DOCS_PLUS_LLM_API_KEY or OPENAI_API_KEY.",
10
+ "empty_response": "The model returned empty content.",
11
+ "invalid_json": "The model returned invalid JSON.",
12
+ "non_object": "The model did not return a JSON object.",
13
+ "schema_too_large": "The operation schema and history are too large ({size} characters); the limit is {limit}.",
14
+ "validation_failed": "Both attempts failed schema validation; nothing was cached: {problem}",
15
+ "body_required": "requestBody is required, but body is null.",
16
+ "empty_cache": "No AI-generated data is cached for this operation. Click AI Generate first.",
17
+ "operation_not_found": "Operation not found in OpenAPI: {method} {path}",
18
+ "service_error": "The AI service is unavailable. Please check the service configuration or try again later.",
19
+ },
20
+ "zh": {
21
+ "missing_key": "未配置 LLM 凭据,请设置环境变量 DOCS_PLUS_LLM_API_KEY 或 OPENAI_API_KEY。",
22
+ "empty_response": "模型返回了空内容。",
23
+ "invalid_json": "模型返回的内容不是合法 JSON。",
24
+ "non_object": "模型返回的不是 JSON 对象。",
25
+ "schema_too_large": "该接口的 schema 和历史过大({size} 字符),已超出上限 {limit}。",
26
+ "validation_failed": "两次生成均未通过 schema 校验,未缓存:{problem}",
27
+ "body_required": "requestBody 是必填的,但 body 为 null。",
28
+ "empty_cache": "该接口暂无 AI 生成缓存,请先点击「AI 生成」。",
29
+ "operation_not_found": "OpenAPI 中不存在操作:{method} {path}",
30
+ "service_error": "AI 服务暂不可用,请检查服务配置或稍后重试。",
31
+ },
32
+ }
33
+
34
+
35
+ def translate(message_key: str, language: Language = "en", **params: Any) -> str:
36
+ """Look up a localized message and format it with the given parameters.
37
+
38
+ Args:
39
+ message_key: Key into the message dictionary.
40
+ language: Target language (``"en"`` or ``"zh"``).
41
+ **params: Format-string parameters injected via ``str.format``.
42
+
43
+ Returns:
44
+ The formatted localized string.
45
+ """
46
+ return _MESSAGES[language][message_key].format(**params)
47
+
48
+
49
+ def language_instruction(language: Language = "en") -> str:
50
+ """Return a system-level language instruction for the LLM prompt.
51
+
52
+ Args:
53
+ language: Target language (``"en"`` or ``"zh"``).
54
+
55
+ Returns:
56
+ A short instruction string such as ``"Output language: English."``.
57
+ """
58
+ return {
59
+ "en": "Output language: English.",
60
+ "zh": "Output language: Simplified Chinese.",
61
+ }[language]