add openai api
This commit is contained in:
@@ -7,4 +7,7 @@ OPENOCR_AUTO_DOWNLOAD=true
|
|||||||
OPENOCR_LAYOUT_THRESHOLD=0.4
|
OPENOCR_LAYOUT_THRESHOLD=0.4
|
||||||
OPENOCR_MAX_LENGTH=2048
|
OPENOCR_MAX_LENGTH=2048
|
||||||
OPENOCR_CORS_ORIGINS=*
|
OPENOCR_CORS_ORIGINS=*
|
||||||
|
# Optional. When set, /v1/* endpoints require: Authorization: Bearer <key>
|
||||||
|
OPENOCR_API_KEY=
|
||||||
|
OPENOCR_OPENAI_MODEL=opendoc-0.1b
|
||||||
OPENOCR_LOG_LEVEL=info
|
OPENOCR_LOG_LEVEL=info
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
- `GET /output/...`:访问生成的 Markdown、JSON 和原始文件
|
- `GET /output/...`:访问生成的 Markdown、JSON 和原始文件
|
||||||
- `GET /health`:健康检查
|
- `GET /health`:健康检查
|
||||||
- `GET /docs`:Swagger API 文档
|
- `GET /docs`:Swagger API 文档
|
||||||
|
- `GET /v1/models`:OpenAI 兼容模型列表
|
||||||
|
- `POST /v1/chat/completions`:OpenAI 兼容文档转 Markdown 接口
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,6 +106,78 @@ curl -F "file=@/path/to/page.png" -F "force=true" http://127.0.0.1:8000/api/reco
|
|||||||
|
|
||||||
Markdown 访问地址:`http://服务器IP:8000/output/<文档名>/<文档名>.md`
|
Markdown 访问地址:`http://服务器IP:8000/output/<文档名>/<文档名>.md`
|
||||||
|
|
||||||
|
### OpenAI 兼容接口
|
||||||
|
|
||||||
|
适用于已有 OpenAI SDK / LangChain / 各类 Agent 框架的调用方式。
|
||||||
|
若配置了 `OPENOCR_ROOT_PATH=/openocr`,则路径前缀为 `/openocr/v1/...`。
|
||||||
|
|
||||||
|
**列出模型**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://jsuse.com/openocr/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
**文档转 Markdown(chat/completions)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://jsuse.com/openocr/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "opendoc-0.1b",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Convert this document to markdown"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/page.png"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
也支持 `data:image/png;base64,...` 形式的图片 URL。
|
||||||
|
|
||||||
|
响应中的 `choices[0].message.content` 即为 Markdown 文本。
|
||||||
|
|
||||||
|
若设置了 `OPENOCR_API_KEY`,需附加请求头:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Python 示例(OpenAI SDK):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="https://jsuse.com/openocr/v1",
|
||||||
|
api_key="your-api-key-or-empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="opendoc-0.1b",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Convert to markdown"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/page.png"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.choices[0].message.content)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 日常开发与更新
|
## 日常开发与更新
|
||||||
@@ -203,6 +277,8 @@ docker compose up -d --build # 重新构建并启动
|
|||||||
| `OPENOCR_LAYOUT_THRESHOLD` | `0.4` | 版面分析阈值 |
|
| `OPENOCR_LAYOUT_THRESHOLD` | `0.4` | 版面分析阈值 |
|
||||||
| `OPENOCR_MAX_LENGTH` | `2048` | 识别最大长度 |
|
| `OPENOCR_MAX_LENGTH` | `2048` | 识别最大长度 |
|
||||||
| `OPENOCR_CORS_ORIGINS` | `*` | 跨域来源,多个用逗号分隔 |
|
| `OPENOCR_CORS_ORIGINS` | `*` | 跨域来源,多个用逗号分隔 |
|
||||||
|
| `OPENOCR_API_KEY` | *(空)* | OpenAI 兼容接口 Bearer Token,留空则不校验 |
|
||||||
|
| `OPENOCR_OPENAI_MODEL` | `opendoc-0.1b` | OpenAI 兼容接口使用的模型 ID |
|
||||||
| `OPENOCR_LOG_LEVEL` | `info` | 日志级别 |
|
| `OPENOCR_LOG_LEVEL` | `info` | 日志级别 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .engine import get_doc_parser, parse_to_outputs
|
from .engine import get_doc_parser, parse_to_outputs
|
||||||
|
from .openai_routes import router as openai_router
|
||||||
from .schemas import HistoryItem, HistoryResponse, RecognizeResponse
|
from .schemas import HistoryItem, HistoryResponse, RecognizeResponse
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.include_router(openai_router)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def warm_up_model() -> None:
|
async def warm_up_model() -> None:
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
|
|||||||
layout_threshold: float = Field(default=0.4, alias="OPENOCR_LAYOUT_THRESHOLD")
|
layout_threshold: float = Field(default=0.4, alias="OPENOCR_LAYOUT_THRESHOLD")
|
||||||
max_length: int = Field(default=2048, alias="OPENOCR_MAX_LENGTH")
|
max_length: int = Field(default=2048, alias="OPENOCR_MAX_LENGTH")
|
||||||
cors_origins: str = Field(default="*", alias="OPENOCR_CORS_ORIGINS")
|
cors_origins: str = Field(default="*", alias="OPENOCR_CORS_ORIGINS")
|
||||||
|
api_key: str = Field(default="", alias="OPENOCR_API_KEY")
|
||||||
|
openai_model_id: str = Field(default="opendoc-0.1b", alias="OPENOCR_OPENAI_MODEL")
|
||||||
log_level: Literal["debug", "info", "warning", "error"] = Field(
|
log_level: Literal["debug", "info", "warning", "error"] = Field(
|
||||||
default="info",
|
default="info",
|
||||||
alias="OPENOCR_LOG_LEVEL",
|
alias="OPENOCR_LOG_LEVEL",
|
||||||
|
|||||||
@@ -29,3 +29,16 @@ def parse_to_outputs(input_path: Path, output_dir: Path) -> dict[str, Any]:
|
|||||||
parser.save_to_markdown(result, str(output_dir))
|
parser.save_to_markdown(result, str(output_dir))
|
||||||
parser.save_to_json(result, str(output_dir))
|
parser.save_to_json(result, str(output_dir))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_to_markdown_text(input_path: Path, output_dir: Path) -> tuple[str, str]:
|
||||||
|
"""Parse a document and return `(doc_id, markdown_text)`."""
|
||||||
|
parse_to_outputs(input_path, output_dir)
|
||||||
|
doc_id = input_path.stem
|
||||||
|
markdown_path = output_dir / doc_id / f"{doc_id}.md"
|
||||||
|
if not markdown_path.exists():
|
||||||
|
candidates = sorted((output_dir / doc_id).glob("*.md"))
|
||||||
|
if not candidates:
|
||||||
|
raise FileNotFoundError(f"Markdown output not found for document: {doc_id}")
|
||||||
|
markdown_path = candidates[0]
|
||||||
|
return doc_id, markdown_path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .engine import parse_to_markdown_text
|
||||||
|
from .openai_schemas import (
|
||||||
|
ChatCompletionChoice,
|
||||||
|
ChatCompletionMessage,
|
||||||
|
ChatCompletionRequest,
|
||||||
|
ChatCompletionResponse,
|
||||||
|
OpenAIModel,
|
||||||
|
OpenAIModelList,
|
||||||
|
)
|
||||||
|
from .openai_utils import cleanup_temp_file, extract_image_urls, materialize_image_url
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1", tags=["OpenAI Compatible"])
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_api_key(authorization: str | None = Header(default=None)) -> None:
|
||||||
|
if not settings.api_key:
|
||||||
|
return
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.")
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
if token != settings.api_key:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=OpenAIModelList)
|
||||||
|
async def list_models(_: None = Depends(verify_api_key)) -> OpenAIModelList:
|
||||||
|
created = int(time.time())
|
||||||
|
return OpenAIModelList(
|
||||||
|
data=[
|
||||||
|
OpenAIModel(
|
||||||
|
id=settings.openai_model_id,
|
||||||
|
created=created,
|
||||||
|
owned_by="openocr",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/completions", response_model=ChatCompletionResponse)
|
||||||
|
async def chat_completions(
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
_: None = Depends(verify_api_key),
|
||||||
|
) -> ChatCompletionResponse:
|
||||||
|
if request.stream:
|
||||||
|
raise HTTPException(status_code=400, detail="Streaming is not supported.")
|
||||||
|
|
||||||
|
if request.model != settings.openai_model_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Model '{request.model}' not found. Use '{settings.openai_model_id}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
image_urls = extract_image_urls(request.messages)
|
||||||
|
if not image_urls:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="At least one user message with image_url content is required.",
|
||||||
|
)
|
||||||
|
if len(image_urls) > 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Only one image per request is supported.")
|
||||||
|
|
||||||
|
upload_path = materialize_image_url(image_urls[0], settings.upload_dir)
|
||||||
|
try:
|
||||||
|
_, markdown_text = parse_to_markdown_text(upload_path, settings.output_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
finally:
|
||||||
|
cleanup_temp_file(upload_path)
|
||||||
|
|
||||||
|
created = int(time.time())
|
||||||
|
return ChatCompletionResponse(
|
||||||
|
id=f"chatcmpl-{uuid4().hex}",
|
||||||
|
created=created,
|
||||||
|
model=settings.openai_model_id,
|
||||||
|
choices=[
|
||||||
|
ChatCompletionChoice(
|
||||||
|
index=0,
|
||||||
|
message=ChatCompletionMessage(content=markdown_text),
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIModel(BaseModel):
|
||||||
|
id: str
|
||||||
|
object: Literal["model"] = "model"
|
||||||
|
created: int
|
||||||
|
owned_by: str = "openocr"
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIModelList(BaseModel):
|
||||||
|
object: Literal["list"] = "list"
|
||||||
|
data: list[OpenAIModel]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageContentImageUrl(BaseModel):
|
||||||
|
url: str
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageContentPart(BaseModel):
|
||||||
|
type: Literal["text", "image_url"]
|
||||||
|
text: str | None = None
|
||||||
|
image_url: ChatMessageContentImageUrl | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: Literal["system", "user", "assistant", "tool"]
|
||||||
|
content: str | list[ChatMessageContentPart | dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionRequest(BaseModel):
|
||||||
|
model: str
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
stream: bool = False
|
||||||
|
temperature: float | None = None
|
||||||
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionMessage(BaseModel):
|
||||||
|
role: Literal["assistant"] = "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionChoice(BaseModel):
|
||||||
|
index: int
|
||||||
|
message: ChatCompletionMessage
|
||||||
|
finish_reason: Literal["stop", "length"] = "stop"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionUsage(BaseModel):
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
object: Literal["chat.completion"] = "chat.completion"
|
||||||
|
created: int
|
||||||
|
model: str
|
||||||
|
choices: list[ChatCompletionChoice]
|
||||||
|
usage: ChatCompletionUsage = Field(default_factory=ChatCompletionUsage)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import urlopen
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .openai_schemas import ChatMessage, ChatMessageContentPart
|
||||||
|
|
||||||
|
SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".pdf"}
|
||||||
|
DATA_URL_PATTERN = re.compile(
|
||||||
|
r"^data:(?P<mime>[\w/+.-]+);base64,(?P<data>.+)$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
MIME_SUFFIX_MAP = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _suffix_from_url(url: str) -> str:
|
||||||
|
path = urlparse(url).path
|
||||||
|
suffix = Path(path).suffix.lower()
|
||||||
|
return suffix if suffix in SUPPORTED_SUFFIXES else ".png"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_bytes(content: bytes, upload_dir: Path, suffix: str) -> Path:
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
upload_path = upload_dir / f"openai_{uuid4().hex}{suffix}"
|
||||||
|
upload_path.write_bytes(content)
|
||||||
|
return upload_path
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_data_url(data_url: str, upload_dir: Path) -> Path:
|
||||||
|
match = DATA_URL_PATTERN.match(data_url.strip())
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid image data URL.")
|
||||||
|
|
||||||
|
mime = match.group("mime").lower()
|
||||||
|
suffix = MIME_SUFFIX_MAP.get(mime)
|
||||||
|
if not suffix:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported data URL mime type: {mime}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(match.group("data"), validate=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 image data.") from exc
|
||||||
|
|
||||||
|
return _save_bytes(raw, upload_dir, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_http_url(url: str, upload_dir: Path) -> Path:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported image URL scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
suffix = _suffix_from_url(url)
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=60) as response:
|
||||||
|
content = response.read()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Failed to download image URL: {exc}") from exc
|
||||||
|
|
||||||
|
return _save_bytes(content, upload_dir, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_image_url(image_url: str, upload_dir: Path) -> Path:
|
||||||
|
if image_url.startswith("data:"):
|
||||||
|
return _decode_data_url(image_url, upload_dir)
|
||||||
|
if image_url.startswith(("http://", "https://")):
|
||||||
|
return _download_http_url(image_url, upload_dir)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="image_url must be an http(s) URL or a data: URL.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_image_urls(messages: list[ChatMessage]) -> list[str]:
|
||||||
|
urls: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
if message.role != "user" or isinstance(message.content, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for part in message.content:
|
||||||
|
parsed = (
|
||||||
|
part
|
||||||
|
if isinstance(part, ChatMessageContentPart)
|
||||||
|
else ChatMessageContentPart.model_validate(part)
|
||||||
|
)
|
||||||
|
if parsed.type == "image_url" and parsed.image_url and parsed.image_url.url:
|
||||||
|
urls.append(parsed.image_url.url)
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_temp_file(path: Path) -> None:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from openocr_markdown.openai_schemas import ChatMessage, ChatMessageContentImageUrl, ChatMessageContentPart
|
||||||
|
from openocr_markdown.openai_utils import DATA_URL_PATTERN, extract_image_urls, materialize_image_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_image_urls_from_multimodal_message() -> None:
|
||||||
|
messages = [
|
||||||
|
ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=[
|
||||||
|
ChatMessageContentPart(type="text", text="Convert to markdown"),
|
||||||
|
ChatMessageContentPart(
|
||||||
|
type="image_url",
|
||||||
|
image_url=ChatMessageContentImageUrl(url="https://example.com/page.png"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert extract_image_urls(messages) == ["https://example.com/page.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_data_url(tmp_path) -> None:
|
||||||
|
png_bytes = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
|
||||||
|
path = materialize_image_url(data_url, tmp_path)
|
||||||
|
assert path.exists()
|
||||||
|
assert path.suffix == ".png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_url_pattern_rejects_invalid_input() -> None:
|
||||||
|
assert DATA_URL_PATTERN.match("not-a-data-url") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_image_url_rejects_unsupported_scheme(tmp_path) -> None:
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
materialize_image_url("ftp://example.com/a.png", tmp_path)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
Reference in New Issue
Block a user