gpt-image-2 multi-image + fix: tools need reasoning_effort none on gpt-5.6 (ggg mute bug)
This commit is contained in:
@@ -123,6 +123,7 @@ class AIResponse(AIMessageBase):
|
||||
self.channel = channel
|
||||
self.staff = staff
|
||||
self.picture = picture
|
||||
self.picture_count = 1
|
||||
self.picture_edit = picture_edit
|
||||
self.hack = hack
|
||||
self.vars = ["answer", "answer_needed", "channel", "staff", "picture", "hack"]
|
||||
@@ -188,15 +189,15 @@ class AIResponder(AIResponderBase):
|
||||
messages.append({"role": "user", "content": content})
|
||||
return messages
|
||||
|
||||
async def draw(self, description: str) -> BytesIO:
|
||||
async def draw(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
if self.config.get("leonardo-token") is not None:
|
||||
return await self.draw_leonardo(description)
|
||||
return await self.draw_openai(description)
|
||||
return [await self.draw_leonardo(description)] # single image only, behind config
|
||||
return await self.draw_openai(description, count)
|
||||
|
||||
async def draw_leonardo(self, description: str) -> BytesIO:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def draw_openai(self, description: str) -> BytesIO:
|
||||
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
|
||||
@@ -221,6 +222,10 @@ class AIResponder(AIResponderBase):
|
||||
bool(response.get("picture_edit", False)),
|
||||
bool(response.get("hack", False)),
|
||||
)
|
||||
try:
|
||||
response_message.picture_count = max(1, min(int(response.get("picture_count") or 1), 4)) # IMG-02
|
||||
except (TypeError, ValueError):
|
||||
response_message.picture_count = 1
|
||||
if response_message.staff is not None and response_message.answer is not None:
|
||||
response_message.answer_needed = True
|
||||
if response_message.channel is None:
|
||||
@@ -250,9 +255,6 @@ class AIResponder(AIResponderBase):
|
||||
"""Cheap reply/factual/emoji pre-pass (BEH-01); None = fail open."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def _entry_channel(item: Dict[str, Any]) -> Optional[str]:
|
||||
try:
|
||||
@@ -299,11 +301,10 @@ class AIResponder(AIResponderBase):
|
||||
await asyncio.to_thread(self.store.save_history, self.channel, list(self.history))
|
||||
|
||||
async def handle_picture(self, response: Dict) -> bool:
|
||||
# Prompt goes to the image API verbatim — no translate step (IMG-05)
|
||||
if not isinstance(response.get("picture"), (type(None), str)):
|
||||
logging.warning(f"picture key is wrong in response: {pp(response)}")
|
||||
return False
|
||||
if response.get("picture") is not None:
|
||||
response["picture"] = await self.translate(response["picture"])
|
||||
return True
|
||||
|
||||
def _parse_answer(self, answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -462,7 +462,8 @@ class FjerkroaBot(commands.Bot):
|
||||
"""Send the answer paced, split and with images on the last part (BEH-04/05/06)"""
|
||||
files = None
|
||||
if response.picture is not None:
|
||||
files = [discord.File(fp=await airesponder.draw(response.picture), filename="image.png")]
|
||||
buffers = await airesponder.draw(response.picture, getattr(response, "picture_count", 1))
|
||||
files = [discord.File(fp=buffer, filename=f"image-{index}.png") for index, buffer in enumerate(buffers)]
|
||||
parts = split_answer(response.answer, int(self.config.get("split-threshold", 1200)), int(self.config.get("split-max-parts", 3)))
|
||||
pace = float(self.config.get("typing-chars-per-second", 0) or 0)
|
||||
max_delay = float(self.config.get("typing-max-seconds", 8))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
import openai
|
||||
|
||||
from .ai_responder import AIResponder, exponential_backoff, pp, sanitize_external_text
|
||||
from .ai_responder import AIResponder, exponential_backoff, sanitize_external_text
|
||||
from .igdblib import IGDBQuery
|
||||
from .leonardo_draw import LeonardoAIDrawMixIn
|
||||
from .quota import QuotaLedger
|
||||
@@ -24,10 +24,11 @@ ENVELOPE_SCHEMA = {
|
||||
"channel": {"type": ["string", "null"], "description": "Target channel name, or null for the origin channel."},
|
||||
"staff": {"type": ["string", "null"], "description": "Alert text for the staff channel, or null."},
|
||||
"picture": {"type": ["string", "null"], "description": "Image generation prompt, or null."},
|
||||
"picture_count": {"type": "integer", "description": "How many images to generate (1-4), 1 unless more were asked for."},
|
||||
"picture_edit": {"type": "boolean", "description": "Whether the picture refers to an earlier image."},
|
||||
"hack": {"type": "boolean", "description": "Whether the user tried to manipulate the assistant."},
|
||||
},
|
||||
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"],
|
||||
"required": ["answer", "answer_needed", "channel", "staff", "picture", "picture_count", "picture_edit", "hack"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
ENVELOPE_RESPONSE_FORMAT = {"type": "json_schema", "json_schema": {"name": "envelope", "strict": True, "schema": ENVELOPE_SCHEMA}}
|
||||
@@ -92,10 +93,7 @@ async def openai_chat(client, *args, **kwargs):
|
||||
|
||||
|
||||
async def openai_image(client, *args, **kwargs):
|
||||
response = await client.images.generate(*args, **kwargs)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(response.data[0].url) as image:
|
||||
return BytesIO(await image.read())
|
||||
return await client.images.generate(*args, **kwargs)
|
||||
|
||||
|
||||
class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
@@ -126,15 +124,26 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
else:
|
||||
logging.warning("❌ IGDB integration DISABLED - missing configuration or disabled in config")
|
||||
|
||||
async def draw_openai(self, description: str) -> BytesIO:
|
||||
async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]:
|
||||
if not self.ledger.budget_ok():
|
||||
raise RuntimeError("daily budget exhausted - refusing image call")
|
||||
model = self.config.get("image-model", "gpt-image-2")
|
||||
kwargs: Dict[str, Any] = {"model": model, "prompt": description, "size": self.config.get("image-size", "1024x1024")}
|
||||
if "image-quality" in self.config:
|
||||
kwargs["quality"] = self.config["image-quality"]
|
||||
if model.startswith("gpt-image"):
|
||||
kwargs["n"] = max(1, min(int(count), 4))
|
||||
else:
|
||||
# legacy models: single image, base64 must be requested (IMG-04)
|
||||
kwargs["n"] = 1
|
||||
kwargs["response_format"] = "b64_json"
|
||||
for _ in range(3):
|
||||
try:
|
||||
response = await openai_image(self.client, prompt=description, n=1, size="1024x1024", model="dall-e-3")
|
||||
self.ledger.add_images(1)
|
||||
logging.info(f"Drawed a picture with DALL-E on this description: {repr(description)}")
|
||||
return response
|
||||
response = await openai_image(self.client, **kwargs)
|
||||
buffers = [BytesIO(base64.b64decode(item.b64_json)) for item in response.data]
|
||||
self.ledger.add_images(len(buffers))
|
||||
logging.info(f"generated {len(buffers)} image(s) on {model} for: {repr(description)}")
|
||||
return buffers
|
||||
except Exception as err:
|
||||
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
|
||||
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
|
||||
@@ -209,6 +218,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
if igdb_functions and isinstance(igdb_functions, list):
|
||||
chat_kwargs["tools"] = [{"type": "function", "function": func} for func in igdb_functions]
|
||||
chat_kwargs["tool_choice"] = "auto"
|
||||
# gpt-5.6 rejects tools + reasoning on chat/completions (ENV-21)
|
||||
chat_kwargs["reasoning_effort"] = self.config.get("reasoning-effort", "none")
|
||||
logging.info(f"🎮 IGDB functions available to AI: {[f['name'] for f in igdb_functions]}")
|
||||
logging.debug(f" Full chat_kwargs with tools: {list(chat_kwargs.keys())}")
|
||||
except (TypeError, AttributeError) as e:
|
||||
@@ -343,27 +354,6 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
|
||||
logging.debug(f"Full traceback: {traceback.format_exc()}")
|
||||
return None, limit
|
||||
|
||||
async def translate(self, text: str, language: str = "english") -> str:
|
||||
if "fix-model" not in self.config:
|
||||
return text
|
||||
message = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"You are an professional translator to {language} language,"
|
||||
f" you translate everything you get directly to {language}"
|
||||
f" if it is not already in {language}, otherwise you just copy it.",
|
||||
},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
try:
|
||||
result = await openai_chat(self.client, model=self.config["fix-model"], messages=message)
|
||||
response = result.choices[0].message.content
|
||||
logging.info(f"got this translated message:\n{pp(response)}")
|
||||
return response
|
||||
except Exception as err:
|
||||
logging.warning(f"failed to translate the text: {repr(err)}")
|
||||
return text
|
||||
|
||||
async def classify(self, message: Any, history_tail: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""~100-token reply/factual/emoji verdict on classifier-model (BEH-01/03)."""
|
||||
if "classifier-model" not in self.config or not self.ledger.budget_ok():
|
||||
|
||||
Reference in New Issue
Block a user