From 8eb6b7a7f9b3d81649460301804201b6b20c8cef Mon Sep 17 00:00:00 2001 From: JOUNGWOOK KWON Date: Mon, 30 Mar 2026 18:40:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Groq=20fallback=20writer=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20Gemini=20rate=20limit=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GroqWriter 클래스 추가 (llama-3.3-70b-versatile) - FallbackWriter 래퍼: primary 실패/빈응답 → fallback chain 자동 시도 - engine.json에 groq 설정 + fallback_chain: ["groq"] 추가 Co-Authored-By: Claude Opus 4.6 --- bots/engine_loader.py | 99 +++++++++++++++++++++++++++++++++++++++++-- config/engine.json | 9 +++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/bots/engine_loader.py b/bots/engine_loader.py index 7765060..1806d6c 100644 --- a/bots/engine_loader.py +++ b/bots/engine_loader.py @@ -194,6 +194,41 @@ class GeminiWriter(BaseWriter): return '' +class GroqWriter(BaseWriter): + """Groq API를 사용하는 글쓰기 엔진 (Gemini fallback용)""" + + def __init__(self, cfg: dict): + self.api_key = os.getenv(cfg.get('api_key_env', 'GROQ_API_KEY'), '') + self.model = cfg.get('model', 'llama-3.3-70b-versatile') + self.max_tokens = cfg.get('max_tokens', 4096) + self.temperature = cfg.get('temperature', 0.7) + + def write(self, prompt: str, system: str = '') -> str: + if not self.api_key: + logger.warning("GROQ_API_KEY 없음 — GroqWriter 비활성화") + return '' + try: + from groq import Groq + client = Groq(api_key=self.api_key) + messages = [] + if system: + messages.append({'role': 'system', 'content': system}) + messages.append({'role': 'user', 'content': prompt}) + response = client.chat.completions.create( + model=self.model, + messages=messages, + max_tokens=self.max_tokens, + temperature=self.temperature, + ) + return response.choices[0].message.content or '' + except ImportError: + logger.warning("groq 미설치 — GroqWriter 비활성화") + return '' + except Exception as e: + logger.error(f"GroqWriter 오류: {e}") + return '' + + class ClaudeWebWriter(BaseWriter): """Playwright Chromium에 세션 쿠키를 주입해 claude.ai를 자동화하는 Writer @@ -540,6 +575,43 @@ class ExternalGenerator(BaseImageGenerator): return False +# ─── Fallback Writer ────────────────────────────────────── + +class FallbackWriter(BaseWriter): + """Primary writer 실패 시 fallback chain으로 자동 재시도""" + + def __init__(self, primary_name: str, primary: BaseWriter, + fallbacks: list[tuple[str, BaseWriter]]): + self._primary_name = primary_name + self._primary = primary + self._fallbacks = fallbacks # [(provider_name, writer), ...] + + def write(self, prompt: str, system: str = '') -> str: + # 1차: primary + try: + result = self._primary.write(prompt, system) + if result and result.strip(): + return result + logger.warning(f"{self._primary_name} 빈 응답 — fallback 시도") + except Exception as e: + logger.warning(f"{self._primary_name} 실패: {e} — fallback 시도") + + # 2차: fallback chain + for fb_name, fb_writer in self._fallbacks: + try: + logger.info(f"Fallback writer 시도: {fb_name}") + result = fb_writer.write(prompt, system) + if result and result.strip(): + logger.info(f"Fallback 성공: {fb_name}") + return result + logger.warning(f"{fb_name} 빈 응답") + except Exception as e: + logger.warning(f"{fb_name} fallback 실패: {e}") + + logger.error("모든 writer (primary + fallback) 실패") + return '' + + # ─── EngineLoader ─────────────────────────────────────── class EngineLoader: @@ -601,22 +673,43 @@ class EngineLoader: else: logger.warning(f"update_provider: 알 수 없는 카테고리 '{category}'") - def get_writer(self) -> BaseWriter: - """현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환""" + def get_writer(self, with_fallback: bool = True) -> BaseWriter: + """현재 설정된 writing provider에 맞는 BaseWriter 구현체 반환. + with_fallback=True이면 FallbackWriter로 감싸서 자동 재시도.""" writing_cfg = self._config.get('writing', {}) provider = writing_cfg.get('provider', 'claude') options = writing_cfg.get('options', {}).get(provider, {}) + fallback_chain = writing_cfg.get('fallback_chain', []) writers = { 'claude': ClaudeWriter, 'openclaw': OpenClawWriter, 'gemini': GeminiWriter, + 'groq': GroqWriter, 'claude_web': ClaudeWebWriter, 'gemini_web': GeminiWebWriter, } cls = writers.get(provider, ClaudeWriter) logger.info(f"Writer 로드: {provider} ({cls.__name__})") - return cls(options) + primary = cls(options) + + if not with_fallback or not fallback_chain: + return primary + + # fallback 체인 구성 + fallback_writers = [] + for fb_provider in fallback_chain: + if fb_provider == provider: + continue + fb_cls = writers.get(fb_provider) + fb_opts = writing_cfg.get('options', {}).get(fb_provider, {}) + if fb_cls: + fallback_writers.append((fb_provider, fb_cls(fb_opts))) + + if not fallback_writers: + return primary + + return FallbackWriter(provider, primary, fallback_writers) def get_tts(self) -> BaseTTS: """현재 설정된 tts provider에 맞는 BaseTTS 구현체 반환""" diff --git a/config/engine.json b/config/engine.json index 6126796..605f662 100644 --- a/config/engine.json +++ b/config/engine.json @@ -3,7 +3,8 @@ "_updated": "2026-03-29", "writing": { "provider": "gemini", - "_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키", + "fallback_chain": ["groq"], + "_comment_provider": "openclaw=ChatGPT Pro(OAuth), claude_web=Claude Max(웹쿠키), gemini_web=Gemini Pro(웹쿠키), claude=Anthropic API키, gemini=Google AI API키, groq=Groq API키", "options": { "openclaw": { "agent_name": "blog-writer", @@ -29,6 +30,12 @@ "model": "gemini-2.5-flash", "max_tokens": 4096, "temperature": 0.7 + }, + "groq": { + "api_key_env": "GROQ_API_KEY", + "model": "llama-3.3-70b-versatile", + "max_tokens": 4096, + "temperature": 0.7 } } },