Files
wehub-resource-sync 2aaeece67c
Codestyle Check / Lint (push) Has been cancelled
Codestyle Check / Check bypass (push) Has been cancelled
Pipelines-Test / Pipelines-Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:37:14 +08:00

380 lines
15 KiB
Python

# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import asyncio
import json
import logging
import subprocess
from dataclasses import dataclass
from itertools import cycle
from pathlib import Path
from typing import Dict, List
import aiofiles
from openai import AsyncOpenAI
from tenacity import retry, stop_after_attempt, wait_random_exponential
from tqdm.asyncio import tqdm
logger = logging.getLogger(__name__)
@dataclass
class RangeSet:
"""Manage processed line ranges with efficient storage and querying"""
ranges: List[tuple]
def add(self, number: int):
"""Add a number to the range set and merge adjacent ranges"""
new_ranges = []
added = False
for start, end in sorted(self.ranges):
if number < start - 1:
if not added:
new_ranges.append((number, number))
added = True
new_ranges.append((start, end))
elif number == start - 1:
new_ranges.append((number, end))
added = True
elif number <= end:
new_ranges.append((start, end))
added = True
else:
new_ranges.append((start, end))
if not added:
new_ranges.append((number, number))
self.ranges = self.merge_ranges(new_ranges)
@staticmethod
def merge_ranges(ranges: List[tuple]) -> List[tuple]:
"""Merge overlapping or adjacent ranges"""
if not ranges:
return []
sorted_ranges = sorted(ranges)
merged = [sorted_ranges[0]]
for current in sorted_ranges[1:]:
last = merged[-1]
if current[0] <= last[1] + 1:
merged[-1] = (last[0], max(last[1], current[1]))
else:
merged.append(current)
return merged
def contains(self, number: int) -> bool:
"""Check if a number exists in any range"""
for start, end in self.ranges:
if start <= number <= end:
return True
return False
def to_file_format(self) -> str:
"""Serialize ranges to compact string format"""
return ",".join(f"{start}-{end}" if start != end else str(start) for start, end in self.ranges)
@classmethod
def from_file(cls, content: str) -> "RangeSet":
"""Deserialize from string format"""
if not content:
return cls(ranges=[])
ranges = []
for part in content.split(","):
if "-" in part:
start, end = map(int, part.split("-"))
ranges.append((start, end))
else:
num = int(part)
ranges.append((num, num))
return cls(ranges=ranges)
@property
def processed_count(self) -> int:
"""Total number of processed items"""
return sum(end - start + 1 for start, end in self.ranges)
class OpenAIClientPool:
"""Manage round-robin distribution of API clients"""
def __init__(self, base_urls: List[str], api_keys: List[str] = None):
if isinstance(base_urls, str):
base_urls = base_urls.split(",")
if isinstance(api_keys, str):
api_keys = api_keys.split(",")
if api_keys is None:
api_keys = ["NONE" for _ in range(len(base_urls))]
if len(api_keys) != len(base_urls):
raise ValueError("API keys and base URLs should have the same length!")
self.clients = cycle([AsyncOpenAI(base_url=url, api_key=key) for url, key in zip(base_urls, api_keys)])
def get_client(self) -> AsyncOpenAI:
"""Get next available client in rotation"""
return next(self.clients)
class OpenAIProcessor:
"""Async processor for batch processing with OpenAI-compatible APIs"""
def __init__(
self,
input_file: str,
output_file: str,
prompt_key: str,
base_urls: List[str],
api_keys: List[str] = None,
prompt_suffix: str = "",
status_file: str = "status.txt",
concurrency: int = 8,
model: str = "deepseek-r1",
temperature: float = 0.6,
top_p: float = 1.0,
max_tokens: int = 65536,
timeout: int = 3600,
response_key: str = "response",
reasoning_key: str = "reasoning",
):
self.input_file = input_file
self.output_file = output_file
self.client_pool = OpenAIClientPool(base_urls, api_keys)
self.status_file = status_file
self.processed_set = RangeSet([])
self.concurrency = concurrency
self.model = model
self.temperature = temperature
self.top_p = top_p
self.max_tokens = max_tokens
self.total_lines = 0
self.progress_bar = None
self.write_lock = asyncio.Lock()
self.status_lock = asyncio.Lock()
self.prompt_key = prompt_key
self.prompt_suffix = prompt_suffix
self.timeout = timeout
self.response_key = response_key
self.reasoning_key = reasoning_key
self._load_status()
def _load_status(self):
"""Load processing status from file"""
try:
with open(self.status_file, "r", encoding="utf-8") as f:
content = f.read().strip()
self.processed_set = RangeSet.from_file(content)
logger.info(f"Resumed processed ranges: {self.processed_set.to_file_format()}")
except FileNotFoundError:
self.processed_set = RangeSet([])
async def _save_status(self):
"""Save current processing status to file"""
async with self.status_lock:
content = self.processed_set.to_file_format()
async with aiofiles.open(self.status_file, "w", encoding="utf-8") as f:
await f.write(content)
def _count_total_lines(self) -> int:
"""Count total lines in input file"""
try:
result = subprocess.run(
["wc", "-l", self.input_file], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
return int(result.stdout.strip().split()[0])
except Exception as e:
print(f"Failed to count lines using `wc -l` command: {str(e)}")
with open(self.input_file, "r", encoding="utf-8") as f:
return sum(1 for _ in f)
async def _line_generator(self):
"""Generate unprocessed lines with line numbers"""
self.total_lines = self._count_total_lines()
with open(self.input_file, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f):
if not self.processed_set.contains(line_num):
yield line_num, json.loads(line.strip())
@retry(stop=stop_after_attempt(5), wait=wait_random_exponential(multiplier=1, max=60))
async def _call_openai(self, client: AsyncOpenAI, line_num: int, data: Dict[str, str]) -> str:
"""Execute API call with retry logic"""
try:
# Prepend processing instructions to the content
content = data.get(self.prompt_key, "")
if isinstance(content, (tuple, list)):
content = content[0]
response = await client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": content + self.prompt_suffix}],
temperature=self.temperature,
top_p=self.top_p,
max_tokens=self.max_tokens,
timeout=self.timeout,
stream=False,
)
return {"line_num": line_num, **data, **self._parse_response(response)}
except Exception as e:
logger.error(f"API call failed: {str(e)}")
if "rate limit" in str(e).lower():
await asyncio.sleep(5)
raise
def _parse_response(self, response) -> Dict[str, str]:
"""Parse API response into structured format"""
response_text = response.result
reasoning_text = ""
if "</think>" in response_text and response_text.count("</think>") == 1:
reasoning_text, _, response_text = response_text.partition("</think>")
if reasoning_text and not reasoning_text.startswith("<think>"):
reasoning_text = f"<think>\n{reasoning_text.strip()}"
if reasoning_text and not reasoning_text.endswith("</think>"):
reasoning_text = f"{reasoning_text.strip()}\n</think>"
return {
self.response_key: response_text,
self.reasoning_key: reasoning_text,
}
async def _write_result(self, line_num: int, result: Dict[str, str]):
"""Write processed result and update status"""
async with self.write_lock:
# Append result to output file
async with aiofiles.open(self.output_file, "a", encoding="utf-8") as f:
await f.write(json.dumps(result, ensure_ascii=False) + "\n")
# Update processing status
self.processed_set.add(line_num)
await self._save_status()
async def worker(self, queue: asyncio.Queue):
"""Process items from the queue"""
client = self.client_pool.get_client()
while True:
line_num, data = await queue.get()
try:
result = await self._call_openai(client, line_num, data)
await self._write_result(line_num, result)
self.progress_bar.update(1)
except Exception as e:
logger.error(f"Failed to process line {line_num}: {str(e)}")
finally:
queue.task_done()
async def run(self):
"""Main processing loop"""
total_lines = self._count_total_lines()
remaining = total_lines - self.processed_set.processed_count
if remaining <= 0:
logger.info("No data requires distilling!")
return
# Initialize progress bar with current progress
self.progress_bar = tqdm(
total=total_lines,
desc=f"[{self.model}] Data Distilling Progress",
dynamic_ncols=True,
initial=self.processed_set.processed_count,
)
queue = asyncio.Queue(maxsize=self.concurrency * 2)
# Ensure output file exists
async with aiofiles.open(self.output_file, "a", encoding="utf-8"):
pass
# Start worker tasks
workers = [asyncio.create_task(self.worker(queue)) for _ in range(self.concurrency)]
try:
# Feed unprocessed items to queue
async for line_num, data in self._line_generator():
await queue.put((line_num, data))
await queue.join()
finally:
# Cleanup resources
for worker_task in workers:
worker_task.cancel()
await asyncio.gather(*workers, return_exceptions=True)
self.progress_bar.close()
logger.info(f"Processing complete. Total processed: {self.processed_set.processed_count} / {total_lines}.")
logger.info("=" * 100)
def main():
parser = argparse.ArgumentParser(description="OpenAI Multi-Processing Interface")
parser.add_argument("--input_file", type=Path, required=True, help="Input JSONL filename")
parser.add_argument("--output_dir", type=Path, required=True, help="Output directory path")
parser.add_argument("--prompt_key", required=True, help="Key name for the input JSONL data prompt")
parser.add_argument("--response_key", required=True, help="Key name for the output JSONL data response")
parser.add_argument("--reasoning_key", required=True, help="Key name for the output JSONL data reasoning")
parser.add_argument("--base_urls", required=True, help="Comma-separated list of API endpoints")
parser.add_argument("--api_keys", default=None, help="Comma-separated list of API keys, Default: `None`")
parser.add_argument("--model", default="deepseek-r1", type=str, help="Model name to use, Default: `deepseek-r1`")
parser.add_argument("--prompt_suffix", default="", type=str, help="Suffix appended after each prompt, Default: ``")
parser.add_argument("--temperature", type=float, default=0.6, help="Sampling temperature, Default: `0.6`")
parser.add_argument("--top_p", type=float, default=1.0, help="Top-p sampling value, Default: `1.0`")
parser.add_argument("--timeout", type=int, default=3600, help="API request timeout in seconds, Default: `3600`s")
parser.add_argument(
"--max_tokens", type=int, default=65536, help="Maximum number of tokens to generate, Default: `65536`"
)
parser.add_argument(
"--concurrency", type=int, default=8, help="Maximum number of concurrent threads, Default: `8`"
)
parser.add_argument("--status_file", default=None, help="Status file path, Default: `None`")
parser.add_argument("--logging_file", default=None, help="Logging file path, Default: `None`")
args = parser.parse_args()
if not str(args.input_file).endswith(".jsonl"):
raise NotImplementedError("Currently only JSONL files are supported!")
args.output_dir.mkdir(parents=True, exist_ok=True)
filename_prefix = "distilled-" + args.input_file.stem
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(args.logging_file or args.output_dir / f"{filename_prefix}.log"),
],
)
PROCESSOR_CONFIG = {
"input_file": args.input_file,
"output_file": args.output_dir / f"{filename_prefix}.jsonl",
"status_file": args.status_file or args.output_dir / f"{filename_prefix}.status",
"prompt_key": args.prompt_key,
"response_key": args.response_key,
"reasoning_key": args.reasoning_key,
"prompt_suffix": args.prompt_suffix,
"base_urls": args.base_urls,
"api_keys": args.api_keys,
"concurrency": args.concurrency,
"model": args.model,
"temperature": args.temperature,
"top_p": args.top_p,
"timeout": args.timeout,
"max_tokens": args.max_tokens,
}
processor = OpenAIProcessor(**PROCESSOR_CONFIG)
asyncio.run(processor.run())
if __name__ == "__main__":
main()