ComfyUI LLM Tool Call
openai package를 사용하는 테스트 코드 작성할거야. 목표: 2가지 모델에 대해 2가지 api type(completions, responses)에 대해 function calling 테스트를 하고 싶어. 총 4개 조합에 대해 설정할 수 있게 코드 작성 하자. 툴은 간단히 특정 폴더의 파일 조회, 특정 폴더의 파일 읽기 두 가지 툴을 구현할거야 2개 모델에 대한 엔드포인트, api 키, 모델명을 코드 내에서 설정할 수 있도록 할 것.
좋아! 아래는 “두 모델 × 두 API 타입(completions, responses) × 함수 호출(function calling)” 조합을 한 파일에서 모두 테스트할 수 있는 파이썬 스크립트야.
요구사항 정리대로
-
두 가지 파일 관련 툴(폴더 내 파일 목록 조회, 파일 읽기)
-
2개 모델에 대한 엔드포인트·API 키·모델명을 코드 내에서 설정 가능
-
4가지 조합을 원하는 대로 실행 가능
을 모두 담았어. 최신 OpenAI Python SDK의 사용법과 Responses API의 툴 호출 흐름은 공식 문서의 구조를 따랐어. 참고: function calling과 tools 정의 방식은 공식 가이드와 동일하고, Chat Completions API에서는 tool_calls를 message에, Responses API에서는 output 항목과 tool_calls를 통해 주고받는 형태야. 관련 문서: Function Calling 가이드와 Responses API 레퍼런스. OpenAI 플랫폼+1
스크립트는 다음처럼 동작해
-
MODELS 섹션에서 model A/B의 endpoint, api_key, model 이름을 채워둔다(예시 포함).
-
TOOL 정의는 공통(JSON Schema).
-
run_with_chat_completions(…), run_with_responses(…) 두 함수가 각각 함수 호출 루프를 수행.
-
main에서 네 가지 조합을 순차 실행하거나, 원하는 조합만 선택 실행하도록 CLI 옵션 제공.
코드 복사해서 test_openai_tools.py로 저장한 뒤, samples 폴더에 테스트용 파일을 몇 개 넣고 돌려봐.
# test_openai_tools.py
# Python 3.10+
# pip install --upgrade openai
import os
import json
import argparse
from dataclasses import dataclass
from typing import Dict, Any, List, Optional
from openai import OpenAI
# =========================
# 1) 구성(모델/엔드포인트/키)
# =========================
@dataclass
class ModelConfig:
name: str
base_url: str # ex) "https://api.openai.com/v1"
api_key: str # ex) os.getenv("OPENAI_API_KEY")
# 여기서 두 모델에 대한 엔드포인트/키/모델명을 설정하세요.
# 실제 키를 코드에 하드코딩하지 말고, 환경변수를 권장합니다.
MODELS = {
"A": ModelConfig(
name="gpt-4.1-mini", # 예시 (원하는 최신 모델명으로 변경)
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY", "YOUR_KEY_FOR_MODEL_A")
),
"B": ModelConfig(
name="gpt-4o-mini", # 예시 (원하는 최신 모델명으로 변경)
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B")
),
}
# =====================================
# 2) 로컬 파일 툴(폴더 목록/파일 읽기)
# =====================================
def list_files_in_folder(folder_path: str) -> List[str]:
"""지정 폴더 내 파일 목록 반환(파일만)."""
try:
items = os.listdir(folder_path)
except FileNotFoundError:
return []
files = [f for f in items if os.path.isfile(os.path.join(folder_path, f))]
return files
def read_text_file(file_path: str) -> str:
"""지정 경로의 텍스트 파일 내용 반환(UTF-8 가정)."""
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
# 바이너리 등 텍스트가 아닌 경우
return ""
# 공통 툴 스키마(JSON Schema; Chat Completions/Responses에서 그대로 사용)
TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {
"type": "string",
"description": "Absolute or relative folder path."
}
},
"required": ["folder_path"],
"additionalProperties": False
}
}
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content as a string.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the text file."
}
},
"required": ["file_path"],
"additionalProperties": False
}
}
}
]
# 툴 디스패처
def dispatch_tool(name: str, arguments: Dict[str, Any]) -> str:
if name == "list_files_in_folder":
folder = arguments.get("folder_path", "")
return json.dumps(list_files_in_folder(folder), ensure_ascii=False)
elif name == "read_text_file":
file_path = arguments.get("file_path", "")
return read_text_file(file_path)
else:
return f"Unknown tool: {name}"
# =====================================
# 3) Chat Completions로 함수 호출 테스트
# =====================================
def run_with_chat_completions(cfg: ModelConfig, user_task: str, tool_choice: str = "auto") -> str:
"""
Chat Completions API로 function calling 테스트.
- tools: TOOLS
- tool_choice: 'auto' 권장
- tool_calls -> 실제 파이썬 함수 실행 -> tool 메시지 첨부 -> 재호출
"""
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": user_task},
]
# 1차 호출
resp = client.chat.completions.create(
model=cfg.name,
messages=messages,
tools=TOOLS,
tool_choice=tool_choice # 'auto' or {'type':'function','function':{'name':'...'}}
)
msg = resp.choices[0].message
# 함수 호출 루프
while getattr(msg, "tool_calls", None):
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
fn_args = json.loads(call.function.arguments or "{}")
tool_output = dispatch_tool(fn_name, fn_args)
# tool 메시지 첨부
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": tool_output
})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
# 후속 호출(최종 답변 유도)
resp = client.chat.completions.create(
model=cfg.name,
messages=messages
)
msg = resp.choices[0].message
return msg.content or ""
# =====================================
# 4) Responses API로 함수 호출 테스트
# - 최신 SDK 규약에 맞춰 비스트리밍 루프 구성
# =====================================
def extract_text_from_responses_output(output_items: List[Any]) -> str:
"""Responses API의 output 배열에서 자연어 텍스트만 모아 반환."""
chunks = []
for item in output_items or []:
# item.type == "message" 인 경우, item.content[*] 중 type == "output_text"
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
chunks.append(getattr(c, "text", ""))
# 일부 모델은 최종에 바로 output_text만 내보내기도 함
if getattr(item, "type", "") == "output_text":
chunks.append(getattr(item, "text", ""))
return "\n".join([t for t in chunks if t])
def extract_tool_calls_from_responses_output(output_items: List[Any]) -> List[Dict[str, Any]]:
"""
Responses API의 output에서 tool_call 이벤트를 수집.
각 항목은 {id, name, arguments} 형태로 정규화해서 반환.
"""
calls = []
for item in output_items or []:
# 케이스1: item.type == "tool_call"
if getattr(item, "type", "") == "tool_call":
tc = {
"id": getattr(item, "id", None),
"name": getattr(item, "name", None),
"arguments": {}
}
# arguments는 문자열/딕셔너리 모두 가능성 고려
raw_args = getattr(item, "arguments", {})
if isinstance(raw_args, str):
try:
raw_args = json.loads(raw_args)
except Exception:
raw_args = {}
tc["arguments"] = raw_args or {}
calls.append(tc)
# 케이스2: item.type == "message" 내부 content[*]에 tool_calls 묶여있을 수 있음
if getattr(item, "type", "") == "message":
# 일부 SDK 버전에서 item.tool_calls 또는 item.content[*].tool_calls 형태
inner_calls = getattr(item, "tool_calls", None)
if inner_calls:
for c in inner_calls:
arg_obj = c.function.arguments if hasattr(c, "function") else {}
if isinstance(arg_obj, str):
try:
arg_obj = json.loads(arg_obj)
except Exception:
arg_obj = {}
calls.append({
"id": getattr(c, "id", None),
"name": getattr(c.function, "name", None) if hasattr(c, "function") else getattr(c, "name", None),
"arguments": arg_obj or {}
})
return calls
def run_with_responses(cfg: ModelConfig, user_task: str, tool_choice: str = "auto") -> str:
"""
Responses API로 function calling 테스트.
흐름:
1) responses.create(input=[{role:user, content:...}], tools=TOOLS, tool_choice=...)
2) tool_call이 있으면 로컬 함수 실행
3) responses.create(..., tool_outputs=[{tool_call_id, output}], response_id=prev.id) 로 이어서 호출
(최신 문서의 규약을 따름)
"""
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
# 1차 호출
r = client.responses.create(
model=cfg.name,
input=[{"role": "user", "content": user_task}],
tools=TOOLS,
tool_choice=tool_choice
)
# 루프: 도구 호출이 끝날 때까지
final_text_parts: List[str] = []
guard = 0
while guard < 6: # 안전 상한
guard += 1
tool_calls = extract_tool_calls_from_responses_output(getattr(r, "output", []))
plain_text = extract_text_from_responses_output(getattr(r, "output", []))
if plain_text:
final_text_parts.append(plain_text)
if not tool_calls:
break
tool_outputs_payload = []
for tc in tool_calls:
out = dispatch_tool(tc["name"], tc["arguments"])
tool_outputs_payload.append({
"tool_call_id": tc["id"],
"output": out
})
# 이전 response와 이어 붙여 툴 결과를 전달
r = client.responses.create(
model=cfg.name,
tools=TOOLS,
response_id=r.id,
tool_outputs=tool_outputs_payload
)
return "\n".join([p for p in final_text_parts if p])
# =====================================
# 5) 실행부(4가지 조합)
# =====================================
DEFAULT_USER_TASK = (
"테스트: ./samples 폴더 안의 파일 목록을 보여주고, 텍스트 파일이 하나라도 있으면 "
"그 중 하나의 내용을 읽어서 핵심만 2~3문장으로 요약해줘. 필요하면 툴을 사용해."
)
def run_one(combo_api: str, model_key: str, user_task: str):
cfg = MODELS[model_key]
print(f"\n=== [{combo_api.upper()}] with Model {model_key} ({cfg.name}) ===")
if combo_api == "completions":
out = run_with_chat_completions(cfg, user_task)
elif combo_api == "responses":
out = run_with_responses(cfg, user_task)
else:
raise ValueError("combo_api must be 'completions' or 'responses'")
print("\n--- OUTPUT ---")
print(out or "(empty)")
def main():
parser = argparse.ArgumentParser(description="Test function-calling with OpenAI (4-way matrix).")
parser.add_argument("--api", choices=["completions", "responses", "all"], default="all",
help="Which API type to run.")
parser.add_argument("--model", choices=["A", "B", "both"], default="both",
help="Which model to use.")
parser.add_argument("--task", type=str, default=DEFAULT_USER_TASK, help="User task/prompt.")
args = parser.parse_args()
apis = ["completions", "responses"] if args.api == "all" else [args.api]
models = ["A", "B"] if args.model == "both" else [args.model]
# 샘플 폴더 안내
os.makedirs("./samples", exist_ok=True)
for api in apis:
for m in models:
run_one(api, m, args.task)
if __name__ == "__main__":
main()
사용 방법 간단 정리
- 환경 변수 세팅(권장)
-
Model A
-
OPENAI_API_KEY=…
-
OPENAI_BASE_URL=https://api.openai.com/v1
-
-
Model B
-
OPENAI_API_KEY_B=…
-
OPENAI_BASE_URL_B=https://api.openai.com/v1
-
- 테스트 파일 준비
- 프로젝트 루트에 samples 폴더를 만들고, 텍스트 파일을 1개 이상 넣는다. 예: samples/readme.txt
- 실행
-
네 조합 모두:
python test_openai_tools.py -
특정 API만:
python test_openai_tools.py –api completions
python test_openai_tools.py –api responses -
특정 모델만:
python test_openai_tools.py –model A
python test_openai_tools.py –model B -
프롬프트 커스터마이즈:
python test_openai_tools.py –task "여기에 원하는 테스트 지시문"
구현 포인트 설명(필요 시 점검할 체크리스트)
-
Chat Completions: response.choices[0].message.tool_calls를 확인하고, 각 툴을 실행한 뒤 tool 메시지를 추가해서 재호출하는 전형적 루프를 사용했어. 이것은 Function Calling 가이드의 권고 패턴이야. OpenAI 플랫폼
-
Responses API: responses.create(…) → output에서 tool_call 이벤트를 수집 → 실제 함수 실행 → tool_outputs와 이전 response_id를 함께 넘겨 후속 responses.create(…) 호출로 이어 붙이는 패턴이야. 이 흐름은 공식 Responses API 레퍼런스가 안내하는 방식과 맞물려 있어. OpenAI 플랫폼
-
tools 스키마는 두 API에서 동일하게 재사용.
-
로컬 파일 I/O는 안전하게 예외 처리했고, 텍스트 파일로 가정(UTF-8)했지만 디코딩 실패 시 빈 문자열을 반환해 모델이 적절히 안내하도록 했어.
문서 참고 링크
-
Function Calling 가이드(툴 정의/호출 흐름) OpenAI 플랫폼
-
Responses API 레퍼런스(응답 구조, tool_outputs 전달 방식) OpenAI 플랫폼
필요하면
-
모델명만 바꿔서(예: 최신 gpt-4.1 계열 등) 즉시 시험 가능
-
tool_choice를 "any"로 바꾸면 여러 툴 병렬 호출 경향을 더 시험할 수 있어
-
samples 폴더 경로나 읽을 파일 선택 로직을 더 엄격하게 지정하고 싶다면, 프롬프트를 고정(seed task)하거나 시스템 메시지쪽에 규칙을 더해도 좋아
원하는 부분 더 바꿔드릴게.
아니 내가 조사했을 때 responses용 툴 json이 있고, completion용이 따로 있어 조금 다르게 생겼던데 찾아서 반영해줘
좋은 지적이야. 실제로 “도구 정의(tools JSON)”는 두 API가 거의 같지만, Responses API는 실행·상태 전파 방식(response_id, tool_outputs 등)과 출력 구조가 다르고, 가이드에선 Responses 예시에서 엄격 파싱(strict) 옵션을 곁들여 쓰는 패턴이 자주 보여서 모양이 살짝 달라 보일 수 있어. 그래서 아래처럼 아예 두 벌의 스키마를 분리해 두고, Responses 쪽엔 엄격 파싱을 넣는 식으로 반영했어. 또한 Responses는 tool_outputs와 response_id를 통해 단계적으로 이어붙이는 점을 코드에 명확히 했어. 참고 문서: Function Calling 가이드와 Responses API 레퍼런스. OpenAI 플랫폼+2
아래 스크립트는 네 가지 조합(모델 A/B × completions/responses) 모두 함수 호출을 시험하고, 두 종류의 파일 툴을 제공해. 변경 포인트는 1) CHAT_TOOLS vs RESPONSES_TOOLS 분리, 2) Responses 루프에서 tool_outputs·response_id 사용을 더 엄격히, 3) Responses 출력 파서 보강이야.
# test_openai_tools_split_tools.py
# pip install --upgrade openai
import os, json, argparse
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from openai import OpenAI
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
MODELS = {
"A": ModelConfig(
name=os.getenv("MODEL_A_NAME", "gpt-4.1-mini"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY", "YOUR_KEY_FOR_MODEL_A"),
),
"B": ModelConfig(
name=os.getenv("MODEL_B_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B"),
),
}
# 공통 실제 구현 함수
import os
def list_files_in_folder(folder_path: str) -> List[str]:
try:
return [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]
except FileNotFoundError:
return []
def read_text_file(file_path: str) -> str:
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
return ""
def dispatch_tool(name: str, args: Dict[str, Any]) -> str:
if name == "list_files_in_folder":
return json.dumps(list_files_in_folder(args.get("folder_path", "")), ensure_ascii=False)
if name == "read_text_file":
return read_text_file(args.get("file_path", ""))
return f"Unknown tool: {name}"
# Chat Completions 전용 도구 JSON
# - 전통적인 function calling 스키마
CHAT_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
}
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Path to the text file."}
},
"required": ["file_path"],
"additionalProperties": False
}
},
},
]
# Responses 전용 도구 JSON
# - 스키마 필드는 유사하지만, Responses 예제 관례에 맞춰 strict를 켬
# (모델이 schema에 정확히 맞춰 인자 생성하도록 강제)
RESPONSES_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
"strict": True
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Path to the text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
"strict": True
},
},
]
def run_with_chat_completions(cfg: ModelConfig, user_task: str, tools_choice: str = "auto") -> str:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": user_task},
]
resp = client.chat.completions.create(
model=cfg.name, messages=messages, tools=CHAT_TOOLS, tool_choice=tools_choice
)
msg = resp.choices[0].message
while getattr(msg, "tool_calls", None):
# 각 툴 실행
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
fn_args = json.loads(call.function.arguments or "{}")
output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name, "content": output})
# 대화 이어붙이기
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
resp = client.chat.completions.create(model=cfg.name, messages=messages)
msg = resp.choices[0].message
return msg.content or ""
# Responses 출력 파서
def _extract_text(output_items: List[Any]) -> str:
chunks: List[str] = []
for item in output_items or []:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
chunks.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
chunks.append(getattr(item, "text", ""))
return "\n".join([t for t in chunks if t])
def _extract_tool_calls(output_items: List[Any]) -> List[Dict[str, Any]]:
calls: List[Dict[str, Any]] = []
for item in output_items or []:
if getattr(item, "type", "") == "tool_call":
raw_args = getattr(item, "arguments", {})
if isinstance(raw_args, str):
try: raw_args = json.loads(raw_args)
except Exception: raw_args = {}
calls.append({"id": getattr(item, "id", None), "name": getattr(item, "name", None), "arguments": raw_args or {}})
if getattr(item, "type", "") == "message":
inner_calls = getattr(item, "tool_calls", None)
if inner_calls:
for c in inner_calls:
raw_args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(raw_args, str):
try: raw_args = json.loads(raw_args)
except Exception: raw_args = {}
calls.append({
"id": getattr(c, "id", None),
"name": getattr(c.function, "name", None) if hasattr(c, "function") else getattr(c, "name", None),
"arguments": raw_args or {}
})
return calls
def run_with_responses(cfg: ModelConfig, user_task: str, tools_choice: str = "auto") -> str:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
r = client.responses.create(
model=cfg.name,
input=[{"role": "user", "content": user_task}],
tools=RESPONSES_TOOLS,
tool_choice=tools_choice
)
final_text: List[str] = []
# Responses는 tool_outputs + response_id로 상태를 이어서 보낸다.
# 이어붙이기 패턴은 공식 레퍼런스의 흐름과 동일. :contentReference[oaicite:1]{index=1}
for _ in range(8): # 안전 상한
txt = _extract_text(getattr(r, "output", []))
if txt:
final_text.append(txt)
calls = _extract_tool_calls(getattr(r, "output", []))
if not calls:
break
tool_outputs = []
for tc in calls:
out = dispatch_tool(tc["name"], tc["arguments"])
tool_outputs.append({"tool_call_id": tc["id"], "output": out})
r = client.responses.create(
model=cfg.name,
tools=RESPONSES_TOOLS,
response_id=r.id, # 이전 응답과 연결
tool_outputs=tool_outputs # 툴 실행 결과 전달
)
return "\n\n".join([t for t in final_text if t]) or ""
DEFAULT_TASK = (
"테스트: ./samples 폴더의 파일 목록을 보여주고, 텍스트 파일이 있으면 하나 골라 내용을 읽고 2~3문장 요약. 필요하면 툴 사용."
)
def run_one(which_api: str, model_key: str, user_task: str):
cfg = MODELS[model_key]
print(f"\n=== [{which_api.upper()}] Model {model_key} ({cfg.name}) ===")
if which_api == "completions":
out = run_with_chat_completions(cfg, user_task)
elif which_api == "responses":
out = run_with_responses(cfg, user_task)
else:
raise ValueError("which_api must be 'completions' or 'responses'")
print("\n--- OUTPUT ---")
print(out or "(empty)")
if __name__ == "__main__":
os.makedirs("./samples", exist_ok=True)
parser = argparse.ArgumentParser()
parser.add_argument("--api", choices=["completions", "responses", "all"], default="all")
parser.add_argument("--model", choices=["A", "B", "both"], default="both")
parser.add_argument("--task", default=DEFAULT_TASK)
args = parser.parse_args()
apis = ["completions", "responses"] if args.api == "all" else [args.api]
models = ["A", "B"] if args.model == "both" else [args.model]
for a in apis:
for m in models:
run_one(a, m, args.task)
핵심 차이 정리 단계별
-
도구 정의 자체는 필드 구조가 동일(type=function, function.name/description/parameters)하되, Responses 쪽 예시에서는 strict를 켜서 JSON Schema를 엄격히 지키게 하는 패턴이 널리 쓰인다. 그래서 스키마가 “조금 다르게 생긴 것처럼” 보일 수 있다. OpenAI 플랫폼+1
-
Chat Completions는 messages 배열에 assistant.tool_calls가 나오고, 각 call.id에 대응하는 tool 메시지를 넣어 다시 chat.completions.create를 호출한다.
-
Responses는 output에 tool_call 항목이 나오며, 실제 도구 실행 후 tool_outputs 배열과 이전 응답의 response_id를 포함해 responses.create를 다시 호출해 상태를 이어 붙인다. 이게 가장 큰 차이점이다. OpenAI 플랫폼
이 버전으로 돌려보고, Responses에서 strict를 끄고 싶으면 RESPONSES_TOOLS에서 "strict": True만 빼면 돼. 모델·엔드포인트·키는 상단 MODELS에서 조정 가능하고, samples 폴더에 테스트 파일만 있으면 바로 네 조합을 모두 확인할 수 있어.
아니 예시를 좀 찾아봐.. from openai import OpenAI import json client = OpenAI() # 1. Define a list of callable tools for the model tools = [ { "type": "function", "name": "get_horoscope", "description": "Get today's horoscope for an astrological sign.", "parameters": { "type": "object", "properties": { "sign": { "type": "string", "description": "An astrological sign like Taurus or Aquarius", }, }, "required": ["sign"], }, }, ] def get_horoscope(sign): return f"{sign}: Next Tuesday you will befriend a baby otter." # Create a running input list we will add to over time input_list = [ {"role": "user", "content": "What is my horoscope? I am an Aquarius."} ] # 2. Prompt the model with tools defined response = client.responses.create( model="gpt-5", tools=tools, input=input_list, ) # Save function call outputs for subsequent requests input_list += response.output for item in response.output: if item.type == "function_call": if item.name == "get_horoscope": # 3. Execute the function logic for get_horoscope horoscope = get_horoscope(json.loads(item.arguments)) # 4. Provide function call results to the model input_list.append({ "type": "function_call_output", "call_id": item.call_id, "output": json.dumps({ "horoscope": horoscope }) }) print("Final input:") print(input_list) response = client.responses.create( model="gpt-5", instructions="Respond only with a horoscope generated by a tool.", tools=tools, input=input_list, ) # 5. The model should be able to give a response! print("Final output:") print(response.model_dump_json(indent=2)) print("\n" + response.output_text) 그러면 총 8개 조합이 나오지? 모델 x API 타입(completion, responses) x json 형태 이 8개 조합에 대해 각각 테스트하는 코드를 짜줘 이 때 실행 중 에러가 나는 경우에는 에러만 출력하고 나머지가 잘 수행되게 해줘.
좋아. “2개 모델 × 2개 API(completions, responses) × 2가지 tools JSON 형태(Completions 스타일 vs Responses 스타일)” = 총 8개 조합을 한 번에 돌려보는 테스트 스크립트를 준비했어. 각 조합은 개별 try/except로 감싸 에러가 나더라도 나머지가 계속 수행돼. tools JSON은 네가 준 Responses 스타일 예시(평평한 형태: type/name/parameters가 최상위)와, Chat Completions에서 흔히 쓰는 중첩 형태({"type":"function","function":{…}})를 각각 분리해 두었고, 일부 조합은 의도적으로 “맞지 않는 형태”를 넣었기 때문에 실패할 수도 있어(그 경우 에러만 출력). 참고로 Responses와 Chat Completions의 차이, strict 옵션 등은 최신 가이드에 정리돼 있어. OpenAI 플랫폼+2
아래 파일을 test_8combos_tools.py로 저장해서 실행하면 돼.
# test_8combos_tools.py
# Python 3.10+
# pip install --upgrade openai
import os
import json
import argparse
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple, Optional
from openai import OpenAI
# =========================
# 0) 모델 설정
# =========================
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
MODELS: Dict[str, ModelConfig] = {
"A": ModelConfig(
name=os.getenv("MODEL_A_NAME", "gpt-4.1-mini"),
base_url=os.getenv("OPENAI_BASE_URL_A", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_A", "YOUR_KEY_FOR_MODEL_A"),
),
"B": ModelConfig(
name=os.getenv("MODEL_B_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B"),
),
}
# =========================
# 1) 로컬 툴 구현
# =========================
def list_files_in_folder(folder_path: str) -> List[str]:
try:
return sorted([
f for f in os.listdir(folder_path)
if os.path.isfile(os.path.join(folder_path, f))
])
except Exception:
return []
def read_text_file(file_path: str) -> str:
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def dispatch_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""
모든 툴의 결과를 dict로 통일해서 반환.
Responses의 function_call_output에 넣기 좋게 {"result": ...} 형태로 감싸준다.
"""
if name == "list_files_in_folder":
folder = args.get("folder_path", "")
return {"result": list_files_in_folder(folder)}
if name == "read_text_file":
path = args.get("file_path", "")
return {"result": read_text_file(path)}
return {"error": f"Unknown tool: {name}"}
# =========================
# 2) tools JSON 두 가지 형태
# =========================
# (A) Chat Completions 스타일: function 필드에 중첩
CHAT_STYLE_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
},
},
]
# (B) Responses 스타일: 네가 준 예시처럼 평평한 형태(일부 문서/예시에서 보이는 포맷)
RESPONSES_STYLE_TOOLS = [
{
"type": "function",
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
# "strict": True # 필요 시 켜서 인자 스키마 준수 강제 (도움말 참조)
},
{
"type": "function",
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
# "strict": True
},
]
# =========================
# 3) Chat Completions 쪽 함수 호출 루프
# =========================
def run_chat_completions(cfg: ModelConfig, user_task: str, tools_variant: str) -> str:
"""
tools_variant: "chat" 또는 "responses"
"chat" -> CHAT_STYLE_TOOLS 사용 (권장)
"responses" -> RESPONSES_STYLE_TOOLS 사용 (의도적 비호환 가능)
"""
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": user_task},
]
resp = client.chat.completions.create(
model=cfg.name,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message
# tool_calls 루프
guard = 0
while getattr(msg, "tool_calls", None) and guard < 8:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
# arguments는 문자열일 수 있음
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False),
})
# 대화 이어붙이기
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
messages.extend(tool_msgs)
resp = client.chat.completions.create(model=cfg.name, messages=messages)
msg = resp.choices[0].message
return msg.content or ""
# =========================
# 4) Responses 쪽 함수 호출 루프
# =========================
def _collect_function_calls_from_output(output: List[Any]) -> List[Dict[str, Any]]:
"""
Responses API의 output 배열에서 function_call / tool_call 류 이벤트를 추출.
다양한 케이스를 최대한 관대하게 처리한다.
"""
calls: List[Dict[str, Any]] = []
for item in output or []:
itype = getattr(item, "type", None)
# 케이스 A: item.type == "function_call"
if itype == "function_call":
name = getattr(item, "name", None)
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({
"call_id": getattr(item, "call_id", None),
"name": name,
"arguments": args or {},
})
# 케이스 B: 일부 모델이 "tool_call" / item.message.tool_calls 형태로 줄 때
if itype == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({
"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {},
})
return calls
def _collect_text_from_output(output: List[Any]) -> str:
texts: List[str] = []
for item in output or []:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
def run_responses(cfg: ModelConfig, user_task: str, tools_variant: str) -> str:
"""
tools_variant: "chat" 또는 "responses"
"responses" -> RESPONSES_STYLE_TOOLS 사용 (권장)
"chat" -> CHAT_STYLE_TOOLS 사용 (의도적 비호환 가능)
순수 Responses 방식: 네가 준 예시 패턴(input_list 축적 + function_call_output)로 구현.
"""
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = RESPONSES_STYLE_TOOLS if tools_variant == "responses" else CHAT_STYLE_TOOLS
# running input_list
input_list: List[Dict[str, Any]] = [
{"role": "user", "content": user_task}
]
r = client.responses.create(
model=cfg.name,
tools=tools,
input=input_list,
)
# function_call 처리
input_list += list(getattr(r, "output", []) or [])
# call들을 찾아 실행하고, function_call_output을 이어 붙임
calls = _collect_function_calls_from_output(getattr(r, "output", []))
for call in calls:
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
# 최종 응답 시도(지시문을 추가적으로 줄 수도 있음)
r2 = client.responses.create(
model=cfg.name,
instructions="Respond with a short summary of what you found.",
tools=tools,
input=input_list,
)
# 텍스트 추출(중간/최종)
final_text = []
txt1 = _collect_text_from_output(getattr(r, "output", []))
if txt1: final_text.append(txt1)
txt2 = _collect_text_from_output(getattr(r2, "output", []))
if txt2: final_text.append(txt2)
return "\n".join([t for t in final_text if t]) or ""
# =========================
# 5) 8개 조합 실행기
# =========================
DEFAULT_TASK = (
"테스트: ./samples 폴더의 파일 목록을 보여주고, 텍스트 파일이 있으면 하나 골라 내용을 읽고 2~3문장 요약. 필요하면 도구를 사용해."
)
def run_one(api_type: str, model_key: str, tools_variant: str, user_task: str) -> None:
cfg = MODELS[model_key]
header = f"[{api_type.upper()}] model={model_key}:{cfg.name} tools_json={tools_variant}"
print("\n" + "=" * (len(header) + 2))
print(f" {header} ")
print("=" * (len(header) + 2))
try:
if api_type == "completions":
out = run_chat_completions(cfg, user_task, tools_variant)
elif api_type == "responses":
out = run_responses(cfg, user_task, tools_variant)
else:
raise ValueError("api_type must be 'completions' or 'responses'")
print("\n--- OUTPUT ---")
print(out or "(empty)")
except Exception as e:
print("\n--- ERROR ---")
print(repr(e))
def main():
parser = argparse.ArgumentParser(description="Run 8 combinations: 2 models × 2 APIs × 2 tools JSON styles.")
parser.add_argument("--task", default=DEFAULT_TASK)
parser.add_argument("--models", choices=["A", "B", "both"], default="both")
parser.add_argument("--apis", choices=["completions", "responses", "both"], default="both")
# tools_variant를 "both"로 두면 각 API에서 chat/responses 두 가지 툴 JSON 모두 시도 → 8조합
parser.add_argument("--tools", choices=["chat", "responses", "both"], default="both")
args = parser.parse_args()
# 샘플 폴더 생성 안내
os.makedirs("./samples", exist_ok=True)
model_keys = ["A", "B"] if args.models == "both" else [args.models]
api_types = ["completions", "responses"] if args.apis == "both" else [args.apis]
tool_variants = ["chat", "responses"] if args.tools == "both" else [args.tools]
# 8조합: 2모델 × 2API × 2JSON
for mk in model_keys:
for api in api_types:
for tv in tool_variants:
run_one(api, mk, tv, args.task)
if __name__ == "__main__":
main()
실행 예시
python test_8combos_tools.py
python test_8combos_tools.py –apis completions –tools both
python test_8combos_tools.py –models A –apis responses –tools responses
포인트 요약
-
CHAT_STYLE_TOOLS와 RESPONSES_STYLE_TOOLS를 분리해 두었고, Responses 루프는 네가 붙여준 예시처럼 input_list에 function_call과 function_call_output을 누적하는 방식으로 구현했어. 일부 문서/예시에선 strict를 켜 JSON 스키마를 강제하니 필요하면 해당 키를 켜면 돼. OpenAI 플랫폼+1
-
일부 조합은 의도적으로 호환되지 않아 에러가 날 수 있어(예: Chat Completions에 Responses 스타일 tools). 모든 run은 try/except로 감싸 실패해도 나머지가 이어진다.
-
최신 차이점과 권장 흐름은 Responses vs Chat Completions 가이드에서 비교 가능해. OpenAI 플랫폼
더 엄격하게 Responses의 “response_id + tool_outputs” 방식으로 이어붙이는 버전도 원하면 따로 추가해줄게.
개선 필요 사항 1. responses api에서도 여러 번 콜을 하고 각 대화를 다 출력하고 싶어. 2. 입력 전체를 그냥 json으로 출력하고, 출력 전체도 그냥 json으로 다 저장해줘. 단, 콘솔 출력 색깔 지정을 통해서 (1) 입력 (2) 툴 요청 (3) 출력 이 구분되면 좋겠어. 3. 추가적으로 json formatting (structured json)도 추가해줘. structured json이 있는 버젼과 없는 버젼 각각 추가해주면 좋겠어. 총 16가지 조합이 될 거야. 각 api에 대한 structured formatting 예시는 아래 코드를 참고해 chat completions
from pydantic import BaseModel
from openai import OpenAI
client = OpenAI()
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
completion = client.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract the event information."},
{"role": "user", "content": "Alice and Bob are going to a science fair on Friday."},
],
response_format=CalendarEvent,
)
event = completion.choices[0].message.parsed
responses
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
],
text_format=CalendarEvent,
)
event = response.output_parsed
좋아, 요구사항(2모델 × 2 API 타입 × 2 Tools JSON 스타일 × 2 Structured 포맷 유무 = 16조합)을 모두 만족하는 단일 실행 스크립트를 준비했어. 다음을 보장해.
-
Responses API에서 여러 번 호출(툴 호출 발생 시 루프)하며, 매 스텝의 대화 전개를 콘솔에 모두 출력.
-
각 조합마다 “입력 전체”와 “출력 전체”를 JSON으로 파일로 저장. 콘솔에선 색상으로 (입력 / 툴요청 / 출력) 구분.
-
Structured JSON 파싱 버전과 일반 버전 각각 제공.
-
에러는 조합별로 try/except로 고립 처리하여, 실패해도 나머지 조합 계속 실행.
아래 코드를 test_16combos.py로 저장해서 실행해. samples 폴더에 텍스트 파일 몇 개 두면 툴 동작을 쉽게 확인할 수 있어.
# test_16combos.py
# Python 3.10+
# pip install --upgrade openai pydantic
import os
import json
import time
import argparse
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from pydantic import BaseModel
from openai import OpenAI
# =========================
# ANSI 색상 유틸
# =========================
RESET = "\033[0m"
CYAN = "\033[36m" # 입력
YELLOW = "\033[33m" # 툴 요청/툴 출력
GREEN = "\033[32m" # 모델 출력
RED = "\033[31m" # 에러
# =========================
# 모델/엔드포인트/키 설정
# =========================
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
MODELS: Dict[str, ModelConfig] = {
"A": ModelConfig(
name=os.getenv("MODEL_A_NAME", "gpt-4.1-mini"),
base_url=os.getenv("OPENAI_BASE_URL_A", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_A", "YOUR_KEY_FOR_MODEL_A"),
),
"B": ModelConfig(
name=os.getenv("MODEL_B_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B"),
),
}
# =========================
# 로컬 툴 구현
# =========================
def list_files_in_folder(folder_path: str) -> List[str]:
try:
return sorted([
f for f in os.listdir(folder_path)
if os.path.isfile(os.path.join(folder_path, f))
])
except Exception:
return []
def read_text_file(file_path: str) -> str:
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def dispatch_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
if name == "list_files_in_folder":
return {"result": list_files_in_folder(args.get("folder_path", ""))}
if name == "read_text_file":
return {"result": read_text_file(args.get("file_path", ""))}
return {"error": f"Unknown tool: {name}"}
# =========================
# Tools JSON 2가지 스타일
# =========================
# Chat Completions 스타일 (function 필드 중첩)
CHAT_STYLE_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
},
},
]
# Responses 스타일 (평평한 형태; 일부 예시 포맷)
RESPONSES_STYLE_TOOLS = [
{
"type": "function",
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
# "strict": True # 필요 시 활성화
},
{
"type": "function",
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
# "strict": True
},
]
# =========================
# Structured JSON 포맷용 Pydantic
# =========================
class FileTaskResult(BaseModel):
files: List[str] = []
chosen_file: Optional[str] = None
summary: Optional[str] = None
# =========================
# 공통: 출력/저장 헬퍼
# =========================
def ensure_dirs(path: str):
os.makedirs(path, exist_ok=True)
def write_json(path: str, obj: Any):
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
def label_for(model_key: str, api_type: str, tools_variant: str, structured: bool) -> str:
return f"{model_key}_{api_type}_{tools_variant}_{'structured' if structured else 'plain'}"
def print_section(title: str, color: str, payload: Any):
print(color + f"\n[{title}]" + RESET)
try:
print(json.dumps(payload, ensure_ascii=False, indent=2))
except Exception:
print(payload)
def now_id() -> str:
return time.strftime("%Y%m%d-%H%M%S")
# =========================
# Chat Completions 러너 (plain/structured)
# =========================
def run_chat(
cfg: ModelConfig,
tools_variant: str,
structured: bool,
user_task: str,
run_dir: str,
) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
# 대화 시작
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": user_task},
]
# 입력/출력 전체 저장을 위한 구조
input_snapshot: Dict[str, Any] = {"messages": messages.copy(), "tools": tools}
responses_dump: List[Dict[str, Any]] = []
print_section("입력(Chat Completions)", CYAN, input_snapshot)
if structured:
# structured: parse() 사용
completion = client.chat.completions.parse(
model=cfg.name,
messages=messages,
tools=tools,
response_format=FileTaskResult,
tool_choice="auto",
)
responses_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
# function calling 루프 (parse 결과에서도 tool_calls 접근 가능하도록 시도)
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = getattr(call.function, "name", None)
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat Completions)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False),
})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
completion = client.chat.completions.parse(
model=cfg.name,
messages=messages,
response_format=FileTaskResult,
)
responses_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
# 최종 구조화 결과
parsed: Optional[FileTaskResult] = getattr(msg, "parsed", None)
out_to_print: Dict[str, Any] = {"text": msg.content, "parsed": parsed.model_dump() if parsed else None}
print_section("출력(Chat Completions, structured)", GREEN, out_to_print)
else:
# plain: create() 사용
resp = client.chat.completions.create(
model=cfg.name, messages=messages, tools=tools, tool_choice="auto"
)
responses_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat Completions)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False),
})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
resp = client.chat.completions.create(model=cfg.name, messages=messages)
responses_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
print_section("출력(Chat Completions, plain)", GREEN, {"text": msg.content})
# 파일 저장
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), responses_dump)
# =========================
# Responses 러너 (plain/structured)
# - 여러 번 호출하며 매 스텝 출력
# =========================
def _collect_function_calls_from_output(output: List[Any]) -> List[Dict[str, Any]]:
calls: List[Dict[str, Any]] = []
for item in output or []:
itype = getattr(item, "type", None)
if itype == "function_call":
name = getattr(item, "name", None)
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None), "name": name, "arguments": args or {}})
if itype == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None), "name": nm, "arguments": args or {}})
return calls
def _collect_text_from_output(output: List[Any]) -> str:
texts: List[str] = []
for item in output or []:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
def run_responses(
cfg: ModelConfig,
tools_variant: str,
structured: bool,
user_task: str,
run_dir: str,
) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = RESPONSES_STYLE_TOOLS if tools_variant == "responses" else CHAT_STYLE_TOOLS
input_list: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": user_task},
]
input_snapshot: Dict[str, Any] = {"input": input_list.copy(), "tools": tools}
print_section("입력(Responses)", CYAN, input_snapshot)
responses_dump: List[Dict[str, Any]] = []
if structured:
# structured: responses.parse(text_format=...)
r = client.responses.parse(
model=cfg.name,
input=input_list,
tools=tools,
text_format=FileTaskResult,
)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
# 툴 호출 탐지 → 실행 → function_call_output 추가 → 이어서 또 parse
guard = 0
while True and guard < 12:
guard += 1
calls = _collect_function_calls_from_output(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.parse(
model=cfg.name,
input=input_list,
tools=tools,
text_format=FileTaskResult,
)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
# 최종 파싱 결과
parsed = getattr(r, "output_parsed", None)
print_section("출력 요약(Responses structured)", GREEN, parsed.model_dump() if parsed else None)
else:
# plain: responses.create()
r = client.responses.create(
model=cfg.name,
input=input_list,
tools=tools,
tool_choice="auto",
)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while True and guard < 12:
guard += 1
calls = _collect_function_calls_from_output(getattr(r, "output", []))
if not calls:
break
tool_outputs = []
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
# function_call_output을 input에 누적
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
# 다음 턴
r = client.responses.create(
model=cfg.name,
input=input_list,
tools=tools,
)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
final_text = _collect_text_from_output(getattr(r, "output", []))
print_section("출력 요약(Responses plain)", GREEN, {"text": final_text})
# 파일 저장
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), responses_dump)
# =========================
# 16개 조합 실행기
# 2 모델 × 2 API × 2 Tools JSON × 2 Structured
# =========================
DEFAULT_TASK = (
"테스트: ./samples 폴더의 파일 목록을 보여주고, 텍스트 파일이 있으면 하나를 골라 내용을 읽고 2~3문장으로 요약해줘. 필요하면 툴을 사용해."
)
def run_one(
model_key: str,
api_type: str,
tools_variant: str,
structured: bool,
user_task: str,
base_runs_dir: str,
):
cfg = MODELS[model_key]
combo_label = label_for(model_key, api_type, tools_variant, structured)
run_dir = os.path.join(base_runs_dir, combo_label)
ensure_dirs(run_dir)
header = f"[{api_type.upper()}] model={model_key}:{cfg.name} tools={tools_variant} structured={'on' if structured else 'off'}"
print("\n" + "=" * (len(header) + 2))
print(f" {header} ")
print("=" * (len(header) + 2))
try:
if api_type == "completions":
run_chat(cfg, tools_variant, structured, user_task, run_dir)
elif api_type == "responses":
run_responses(cfg, tools_variant, structured, user_task, run_dir)
else:
raise ValueError("api_type must be 'completions' or 'responses'")
except Exception as e:
print(RED + "\n--- ERROR ---" + RESET)
print(repr(e))
# 에러만 출력하고 계속
def main():
parser = argparse.ArgumentParser(description="Run 16 combos: 2 models × 2 APIs × 2 Tools JSON × 2 Structured.")
parser.add_argument("--task", default=DEFAULT_TASK)
parser.add_argument("--models", choices=["A", "B", "both"], default="both")
parser.add_argument("--apis", choices=["completions", "responses", "both"], default="both")
parser.add_argument("--tools", choices=["chat", "responses", "both"], default="both")
parser.add_argument("--structured", choices=["on", "off", "both"], default="both")
parser.add_argument("--runs_dir", default=f"./runs/{now_id()}")
args = parser.parse_args()
os.makedirs("./samples", exist_ok=True)
ensure_dirs(args.runs_dir)
model_keys = ["A", "B"] if args.models == "both" else [args.models]
api_types = ["completions", "responses"] if args.apis == "both" else [args.apis]
tool_variants = ["chat", "responses"] if args.tools == "both" else [args.tools]
structured_opts = [True, False] if args.structured == "both" else [args.structured == "on"]
for mk in model_keys:
for api in api_types:
for tv in tool_variants:
for st in structured_opts:
run_one(mk, api, tv, st, args.task, args.runs_dir)
if __name__ == "__main__":
main()
사용법 요약
- 환경 변수 설정(예시)
-
모델 A:
OPENAI_API_KEY_A, OPENAI_BASE_URL_A=https://api.openai.com/v1, MODEL_A_NAME -
모델 B:
OPENAI_API_KEY_B, OPENAI_BASE_URL_B=https://api.openai.com/v1, MODEL_B_NAME
-
샘플 준비
프로젝트 루트에 samples 폴더를 만들고 텍스트 파일을 1개 이상 넣어둔다. -
실행 예시
-
16개 전체 조합 실행
python test_16combos.py -
Responses만, structured/plain 둘 다, tools는 responses 스타일만
python test_16combos.py –apis responses –tools responses –structured both -
모델 A만, Chat Completions만, structured만
python test_16combos.py –models A –apis completions –structured on
동작 정리
-
콘솔 색상으로 입력(청록), 툴 요청(노랑), 출력(초록)을 구분.
-
각 조합은 runs/타임스탬프/모델_API_tools_structured/ 아래에 input.json, outputs.json 저장.
-
Responses API는 툴 호출이 계속 나오면 루프를 반복하고 매 스텝 출력 및 누적 저장.
-
Structured 모드에선 chat.completions.parse / responses.parse를 사용해 Pydantic 모델(FileTaskResult)로 결과를 파싱해 별도 요약도 콘솔에 표시.
원하는 추가 포맷(예: 파일명 규칙, 더 자세한 로깅, strict 옵션 상시 on 등)이 있으면 알려줘.
출력 json 포맷은 task에서 요청한 걸 대답할 수 있게 해주면 좋을 것 같아. 예를 들어 filename: 파일명 contents: 내용
좋아! “결과 JSON 포맷을 task에 바로 쓰이게(filename, contents)”로 맞춘 버전을 아래처럼 업데이트했어. 핵심은 structured 모드에서 파싱 모델을 {"filename": str, "contents": str}로 고정하고, 모델에게 “contents에는 요약을 넣어라”라고 지시하는 것. plain 모드에서는 기존 그대로 자유 텍스트를 출력하고 전체 원시 JSON 로그만 저장하며, structured 모드에서는 추가로 result.json에 {"filename","contents"}를 저장해.
아래 스크립트는 이전 16조합 실행기(test_16combos.py)를 대체하는 완전판이야. 바뀐 점만 빠르게 요약하고 바로 코드 줄게.
변경 요약
- Structured 파싱 모델 교체
-
Pydantic 모델을
TaskAnswer로 정의하고 필드를 filename, contents 두 개로 통일. -
contents는 “읽은 파일의 2~3문장 요약”을 넣도록 system/instructions에 명확히 지시.
- Structured 모드에서 결과 JSON 저장
-
각 조합 폴더에
result.json으로{"filename","contents"}저장. -
기존처럼
input.json,outputs.json도 계속 저장.
- 콘솔 색상 출력은 동일하게 유지
- 입력(CYAN), 툴 요청(YELLOW), 출력(GREEN)으로 구분.
- Responses API는 다단 호출 유지
- 각 step의 출력과 툴 요청을 모두 콘솔에 표시하고, outputs.json에 누적 저장.
코드
# test_16combos_v2.py
# Python 3.10+
# pip install --upgrade openai pydantic
import os
import json
import time
import argparse
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from openai import OpenAI
# ===== ANSI 색상 =====
RESET = "\033[0m"
CYAN = "\033[36m" # 입력
YELLOW = "\033[33m" # 툴 요청/툴 출력
GREEN = "\033[32m" # 모델 출력
RED = "\033[31m" # 에러
# ===== 모델 설정 =====
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
MODELS: Dict[str, ModelConfig] = {
"A": ModelConfig(
name=os.getenv("MODEL_A_NAME", "gpt-4.1-mini"),
base_url=os.getenv("OPENAI_BASE_URL_A", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_A", "YOUR_KEY_FOR_MODEL_A"),
),
"B": ModelConfig(
name=os.getenv("MODEL_B_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B"),
),
}
# ===== 로컬 툴 구현 =====
def list_files_in_folder(folder_path: str) -> List[str]:
try:
return sorted([f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))])
except Exception:
return []
def read_text_file(file_path: str) -> str:
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def dispatch_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
if name == "list_files_in_folder":
return {"result": list_files_in_folder(args.get("folder_path", ""))}
if name == "read_text_file":
return {"result": read_text_file(args.get("file_path", ""))}
return {"error": f"Unknown tool: {name}"}
# ===== Tools JSON 2가지 =====
CHAT_STYLE_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {"folder_path": {"type": "string", "description": "Absolute or relative folder path."}},
"required": ["folder_path"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {"file_path": {"type": "string", "description": "Absolute or relative file path."}},
"required": ["file_path"],
"additionalProperties": False,
},
},
},
]
RESPONSES_STYLE_TOOLS = [
{
"type": "function",
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {"folder_path": {"type": "string", "description": "Absolute or relative folder path."}},
"required": ["folder_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {"file_path": {"type": "string", "description": "Absolute or relative file path."}},
"required": ["file_path"],
"additionalProperties": False,
},
},
]
# ===== Structured JSON 스키마 =====
class TaskAnswer(BaseModel):
filename: str
contents: str # 선택된 파일의 2~3문장 요약(모델이 생성)
# ===== 유틸 =====
def ensure_dirs(path: str): os.makedirs(path, exist_ok=True)
def write_json(path: str, obj: Any):
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
def now_id() -> str: return time.strftime("%Y%m%d-%H%M%S")
def print_section(title: str, color: str, payload: Any):
print(color + f"\n[{title}]" + RESET)
try:
print(json.dumps(payload, ensure_ascii=False, indent=2))
except Exception:
print(payload)
def combo_label(model_key: str, api_type: str, tools_variant: str, structured: bool) -> str:
return f"{model_key}_{api_type}_{tools_variant}_{'structured' if structured else 'plain'}"
# ===== Chat Completions 러너 =====
def run_chat(cfg: ModelConfig, tools_variant: str, structured: bool, user_task: str, run_dir: str) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
system_msg = (
"You are a helpful assistant that uses tools when needed. "
"Task: Show files under ./samples, pick one text file, read it, and provide a 2-3 sentence summary. "
"If using structured output, respond ONLY with JSON having keys: filename, contents."
)
messages = [
{"role": "system", "content": system_msg},
{"role": "user", "content": user_task},
]
input_snapshot = {"messages": messages.copy(), "tools": tools}
responses_dump: List[Dict[str, Any]] = []
print_section("입력(Chat Completions)", CYAN, input_snapshot)
if structured:
completion = client.chat.completions.parse(
model=cfg.name,
messages=messages,
tools=tools,
response_format=TaskAnswer,
tool_choice="auto",
)
responses_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
completion = client.chat.completions.parse(
model=cfg.name,
messages=messages,
response_format=TaskAnswer,
)
responses_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
parsed: Optional[TaskAnswer] = getattr(msg, "parsed", None)
out_obj = parsed.model_dump() if parsed else {"filename": "", "contents": msg.content or ""}
print_section("출력(Chat structured)", GREEN, out_obj)
write_json(os.path.join(run_dir, "result.json"), out_obj)
else:
resp = client.chat.completions.create(
model=cfg.name, messages=messages, tools=tools, tool_choice="auto"
)
responses_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
resp = client.chat.completions.create(model=cfg.name, messages=messages)
responses_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
print_section("출력(Chat plain)", GREEN, {"text": msg.content})
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), responses_dump)
# ===== Responses 러너 =====
def _collect_function_calls(output: List[Any]) -> List[Dict[str, Any]]:
calls: List[Dict[str, Any]] = []
for item in output or []:
itype = getattr(item, "type", None)
if itype == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(item, "call_id", None), "name": getattr(item, "name", None), "arguments": args or {}})
if itype == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(c, "id", None), "name": nm, "arguments": args or {}})
return calls
def _collect_text(output: List[Any]) -> str:
texts: List[str] = []
for item in output or []:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
def run_responses(cfg: ModelConfig, tools_variant: str, structured: bool, user_task: str, run_dir: str) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = RESPONSES_STYLE_TOOLS if tools_variant == "responses" else CHAT_STYLE_TOOLS
system_inst = (
"You are a helpful assistant that uses tools when needed. "
"Task: Show files under ./samples, pick one text file, read it, and provide a 2-3 sentence summary. "
"If structured parsing is enabled, respond ONLY with JSON having keys: filename, contents."
)
input_list: List[Dict[str, Any]] = [
{"role": "system", "content": system_inst},
{"role": "user", "content": user_task},
]
input_snapshot = {"input": input_list.copy(), "tools": tools}
print_section("입력(Responses)", CYAN, input_snapshot)
responses_dump: List[Dict[str, Any]] = []
if structured:
r = client.responses.parse(
model=cfg.name,
input=input_list,
tools=tools,
text_format=TaskAnswer,
)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while guard < 12:
guard += 1
calls = _collect_function_calls(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.parse(model=cfg.name, input=input_list, tools=tools, text_format=TaskAnswer)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
parsed = getattr(r, "output_parsed", None)
out_obj = parsed.model_dump() if parsed else {"filename": "", "contents": _collect_text(getattr(r, "output", []))}
print_section("출력 요약(Responses structured)", GREEN, out_obj)
write_json(os.path.join(run_dir, "result.json"), out_obj)
else:
r = client.responses.create(model=cfg.name, input=input_list, tools=tools, tool_choice="auto")
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while guard < 12:
guard += 1
calls = _collect_function_calls(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.create(model=cfg.name, input=input_list, tools=tools)
responses_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
final_text = _collect_text(getattr(r, "output", []))
print_section("출력 요약(Responses plain)", GREEN, {"text": final_text})
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), responses_dump)
# ===== 16개 조합 실행기 =====
DEFAULT_TASK = (
"테스트: ./samples 폴더의 파일 목록을 보여주고, 텍스트 파일이 있으면 하나를 골라 내용을 읽고 2~3문장으로 요약해줘. 필요하면 툴을 사용해."
)
def run_one(model_key: str, api_type: str, tools_variant: str, structured: bool, user_task: str, base_runs_dir: str):
cfg = MODELS[model_key]
label = combo_label(model_key, api_type, tools_variant, structured)
run_dir = os.path.join(base_runs_dir, label)
ensure_dirs(run_dir)
header = f"[{api_type.upper()}] model={model_key}:{cfg.name} tools={tools_variant} structured={'on' if structured else 'off'}"
print("\n" + "=" * (len(header) + 2))
print(f" {header} ")
print("=" * (len(header) + 2))
try:
if api_type == "completions":
run_chat(cfg, tools_variant, structured, user_task, run_dir)
elif api_type == "responses":
run_responses(cfg, tools_variant, structured, user_task, run_dir)
else:
raise ValueError("api_type must be 'completions' or 'responses'")
except Exception as e:
print(RED + "\n--- ERROR ---" + RESET)
print(repr(e)) # 에러만 출력하고 계속
def main():
parser = argparse.ArgumentParser(description="Run 16 combos with JSON result format {filename, contents}.")
parser.add_argument("--task", default=DEFAULT_TASK)
parser.add_argument("--models", choices=["A", "B", "both"], default="both")
parser.add_argument("--apis", choices=["completions", "responses", "both"], default="both")
parser.add_argument("--tools", choices=["chat", "responses", "both"], default="both")
parser.add_argument("--structured", choices=["on", "off", "both"], default="both")
parser.add_argument("--runs_dir", default=f"./runs/{now_id()}")
args = parser.parse_args()
os.makedirs("./samples", exist_ok=True)
ensure_dirs(args.runs_dir)
model_keys = ["A", "B"] if args.models == "both" else [args.models]
api_types = ["completions", "responses"] if args.apis == "both" else [args.apis]
tool_variants = ["chat", "responses"] if args.tools == "both" else [args.tools]
structured_opts = [True, False] if args.structured == "both" else [args.structured == "on"]
for mk in model_keys:
for api in api_types:
for tv in tool_variants:
for st in structured_opts:
run_one(mk, api, tv, st, args.task, args.runs_dir)
if __name__ == "__main__":
main()
사용 포인트
-
structured 모드일 때만
result.json을 생성하며 내용은 반드시{"filename","contents"}로 저장돼. -
contents에는 “선택한 파일의 2~3문장 요약”이 들어가도록 시스템 메시지에서 강제했어.
-
plain 모드에선 자유 형식 출력이지만, 입력과 모든 원시 응답은
input.json,outputs.json로 저장돼. -
각 단계의 콘솔 출력은 색상으로 구분돼서 입력/툴요청/출력이 한눈에 보여.
원하면 contents를 “파일 전문”으로 바꾸는 옵션 스위치도 추가해줄 수 있어.
Completions(structured)에서 ValueError list_files_in_folder is not strict. Only strict function tools can be auto-parsed.
좋은 피드백이야. 에러 원인은 chat.completions.parse를 사용할 때 툴이 strict 모드가 아니면 자동 파싱이 불가능해서야. 즉, structured 모드에서 함수 툴 정의에 strict=True가 반드시 들어가야 해.
수정 방향 요약 단계별
-
tools_variant와 structured 여부에 따라 툴 JSON에 strict를 주입하는 헬퍼를 만든다.
-
Chat 스타일일 땐 tool["function"]["strict"]=True, Responses 스타일일 땐 tool["strict"]=True를 넣는다.
-
run_chat / run_responses에서 기존 정적 TOOLS 대신 이 헬퍼로 반환된 툴을 사용한다.
아래 변경만 적용하면 돼. 나머지 로직은 그대로 둬도 동작한다.
추가 함수 및 변경점
import copy
def make_tools(tools_variant: str, structured: bool):
"""
tools_variant: 'chat' | 'responses'
structured가 True면 strict 플래그를 주입한다.
"""
base = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
tools = copy.deepcopy(base)
if structured:
# Chat 스타일: "function" 블록 안에 strict
if tools_variant == "chat":
for t in tools:
if t.get("type") == "function" and "function" in t:
t["function"]["strict"] = True
# Responses 스타일: 최상위에 strict
else:
for t in tools:
if t.get("type") == "function":
t["strict"] = True
return tools
run_chat 내부에서 툴 선택 부분 교체
# 기존
# tools = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
# 변경
tools = make_tools(tools_variant, structured)
run_responses 내부에서도 동일하게 교체
# 기존
# tools = RESPONSES_STYLE_TOOLS if tools_variant == "responses" else CHAT_STYLE_TOOLS
# 변경
tools = make_tools(tools_variant, structured)
추가 참고
-
이 변경으로 Completions(structured) 뿐만 아니라 Responses(structured)에서도 일관되게 엄격 파싱이 보장돼.
-
plain 모드에선 strict가 없어도 되지만, 위 헬퍼는 structured일 때만 strict를 주입하므로 기존 동작을 해치지 않아.
-
tools JSON을 동적으로 주입하므로, 새 함수만 추가하고 두 곳 호출부만 바꾸면 끝나.
이렇게 수정하면 “ValueError list_files_in_folder is not strict. Only strict function tools can be auto-parsed.” 에러는 사라지고 16개 조합 모두 정상적으로 순회할 수 있어.
그래도 계속 에러가 나네. 일단 넘어가고 json scheme을 지정하는 방식은 동작을 잘 안하네 일반 json 모드를 켜보고 싶어. 총 3가지 sweep(no structured, json_mode, structured)로 실험해보자. 예시 responses
we_did_not_specify_stop_tokens = True
try:
response = client.responses.create(
model="gpt-3.5-turbo-0125",
input=[
{"role": "system", "content": "You are a helpful assistant designed to output JSON."},
{"role": "user", "content": "Who won the world series in 2020? Please respond in the format {winner: ...}"}
],
text={"format": {"type": "json_object"}}
)
# Check if the conversation was too long for the context window, resulting in incomplete JSON
if response.status == "incomplete" and response.incomplete_details.reason == "max_output_tokens":
# your code should handle this error case
pass
# Check if the OpenAI safety system refused the request and generated a refusal instead
if response.output[0].content[0].type == "refusal":
# your code should handle this error case
# In this case, the .content field will contain the explanation (if any) that the model generated for why it is refusing
print(response.output[0].content[0]["refusal"])
# Check if the model's output included restricted content, so the generation of JSON was halted and may be partial
if response.status == "incomplete" and response.incomplete_details.reason == "content_filter":
# your code should handle this error case
pass
if response.status == "completed":
# In this case the model has either successfully finished generating the JSON object according to your schema, or the model generated one of the tokens you provided as a "stop token"
if we_did_not_specify_stop_tokens:
# If you didn't specify any stop tokens, then the generation is complete and the content key will contain the serialized JSON object
# This will parse successfully and should now contain "{"winner": "Los Angeles Dodgers"}"
print(response.output_text)
else:
# Check if the response.output_text ends with one of your stop tokens and handle appropriately
pass
except Exception as e:
# Your code should handle errors here, for example a network error calling the API
print(e)
completions
response = client.chat.completions.create(
model="gpt-4-1106-preview",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "LangChain은 무엇을 하는 라이브러리지? JSON으로 답해줘. JSON 키워드는 Description, Features, Usage로 해줘."}
],
response_format = {'type':"json_object"}
)
좋아. strict 기반의 JSON Schema 파싱은 접고, 세 모드로 스윕하도록 전체 스크립트를 업데이트했어.
스윕 축: 2모델 × 2 API(completions/responses) × 2 Tools JSON(chat-style/responses-style) × 3 모드(plain, json_mode, structured) = 총 24조합.
핵심
-
plain: 자유 출력. 전체 입력/출력 원본 JSON을 저장.
-
json_mode: 모델에게 “무조건 JSON”을 요구. 출력은 {"filename","contents"} 형식. 저장 시 result.json 생성.
-
Chat Completions: response_format={'type': 'json_object'}
-
Responses: text={"format":{"type":"json_object"}}
-
-
structured: 이전처럼 parse 사용. 여전히 모델/SDK에 따라 strict 요구로 실패할 수도 있으니 try/except로 격리. 성공 시 result.json 생성.
-
Responses API는 툴 호출이 나오면 여러 번 호출되며 매 스텝 콘솔 출력. 콘솔은 색상으로 입력(CYAN), 툴 요청(YELLOW), 출력(GREEN) 구분.
-
모든 조합은 runs/타임스탬프/모델_API_tools_mode/ 폴더에 input.json, outputs.json 저장. json_mode/structured는 result.json 추가 저장.
-
에러는 조합별로만 출력하고 나머지 계속 진행.
코드: test_24combos.py
# test_24combos.py
# Python 3.10+
# pip install --upgrade openai pydantic
import os
import json
import time
import copy
import argparse
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
from openai import OpenAI
# ===== ANSI colors =====
RESET = "\033[0m"
CYAN = "\033[36m" # input
YELLOW = "\033[33m" # tool
GREEN = "\033[32m" # output
RED = "\033[31m" # error
# ===== Model configs =====
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
MODELS: Dict[str, ModelConfig] = {
"A": ModelConfig(
name=os.getenv("MODEL_A_NAME", "gpt-4.1-mini"),
base_url=os.getenv("OPENAI_BASE_URL_A", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_A", "YOUR_KEY_FOR_MODEL_A"),
),
"B": ModelConfig(
name=os.getenv("MODEL_B_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_BASE_URL_B", "https://api.openai.com/v1"),
api_key=os.getenv("OPENAI_API_KEY_B", "YOUR_KEY_FOR_MODEL_B"),
),
}
# ===== Local tools =====
def list_files_in_folder(folder_path: str) -> List[str]:
try:
return sorted([
f for f in os.listdir(folder_path)
if os.path.isfile(os.path.join(folder_path, f))
])
except Exception:
return []
def read_text_file(file_path: str) -> str:
if not os.path.exists(file_path):
return ""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def dispatch_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
if name == "list_files_in_folder":
return {"result": list_files_in_folder(args.get("folder_path", ""))}
if name == "read_text_file":
return {"result": read_text_file(args.get("file_path", ""))}
return {"error": f"Unknown tool: {name}"}
# ===== Tools JSON (two shapes) =====
CHAT_STYLE_TOOLS = [
{
"type": "function",
"function": {
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
},
},
]
RESPONSES_STYLE_TOOLS = [
{
"type": "function",
"name": "list_files_in_folder",
"description": "List file names (not directories) inside a given folder path.",
"parameters": {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "read_text_file",
"description": "Read a UTF-8 text file and return its content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative file path."}
},
"required": ["file_path"],
"additionalProperties": False,
},
},
]
# ===== Structured JSON schema (optional) =====
class TaskAnswer(BaseModel):
filename: str
contents: str
# ===== Helpers =====
def ensure_dirs(path: str): os.makedirs(path, exist_ok=True)
def write_json(path: str, obj: Any):
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
def now_id() -> str: return time.strftime("%Y%m%d-%H%M%S")
def print_section(title: str, color: str, payload: Any):
print(color + f"\n[{title}]" + RESET)
try:
print(json.dumps(payload, ensure_ascii=False, indent=2))
except Exception:
print(payload)
def combo_label(model_key: str, api_type: str, tools_variant: str, mode: str) -> str:
return f"{model_key}_{api_type}_{tools_variant}_{mode}"
def make_tools(tools_variant: str, structured: bool) -> List[Dict[str, Any]]:
# structured에서 strict 문제를 피하기 위해 여기선 strict 주입을 끔
# (structured는 parse() 사용 시 여전히 실패할 수 있으므로 try/except로 감싼다)
base = CHAT_STYLE_TOOLS if tools_variant == "chat" else RESPONSES_STYLE_TOOLS
return copy.deepcopy(base)
# ===== Common parsing for Responses =====
def _collect_function_calls_from_output(output: List[Any]) -> List[Dict[str, Any]]:
calls: List[Dict[str, Any]] = []
for item in output or []:
itype = getattr(item, "type", None)
if itype == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(item, "call_id", None), "name": getattr(item, "name", None), "arguments": args or {}})
if itype == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(c, "id", None), "name": nm, "arguments": args or {}})
return calls
def _collect_text_from_output(output: List[Any]) -> str:
texts: List[str] = []
for item in output or []:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
# ===== Chat Completions runner =====
def run_chat(
cfg: ModelConfig,
tools_variant: str,
mode: str, # "plain" | "json_mode" | "structured"
user_task: str,
run_dir: str,
) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = make_tools(tools_variant, structured=(mode == "structured"))
system_msg = (
"You are a helpful assistant that uses tools when needed. "
"Task: List files in ./samples, pick a text file, read it, and return a short 2-3 sentence summary. "
"When asked to answer in JSON, reply only with an object: {\"filename\": string, \"contents\": string}."
)
messages = [
{"role": "system", "content": system_msg},
{"role": "user", "content": user_task},
]
input_snapshot = {"messages": messages.copy(), "tools": tools, "mode": mode}
outputs_dump: List[Dict[str, Any]] = []
print_section("입력(Chat Completions)", CYAN, input_snapshot)
if mode == "structured":
try:
completion = client.chat.completions.parse(
model=cfg.name,
messages=messages,
tools=tools,
response_format=TaskAnswer,
tool_choice="auto",
)
outputs_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
completion = client.chat.completions.parse(
model=cfg.name, messages=messages, response_format=TaskAnswer
)
outputs_dump.append(json.loads(completion.model_dump_json()))
msg = completion.choices[0].message
parsed = getattr(msg, "parsed", None)
out_obj = parsed.model_dump() if parsed else {"filename": "", "contents": msg.content or ""}
print_section("출력(Chat structured)", GREEN, out_obj)
write_json(os.path.join(run_dir, "result.json"), out_obj)
except Exception as e:
print(RED + "\n--- ERROR(Chat structured) ---" + RESET)
print(repr(e))
elif mode == "json_mode":
# Force JSON output
resp = client.chat.completions.create(
model=cfg.name,
messages=messages,
tools=tools,
tool_choice="auto",
response_format={"type": "json_object"},
)
outputs_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
resp = client.chat.completions.create(
model=cfg.name,
messages=messages,
response_format={"type": "json_object"},
)
outputs_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
# Try to parse JSON
result_obj = {}
try:
result_obj = json.loads(msg.content or "{}")
except Exception:
result_obj = {"filename": "", "contents": msg.content or ""}
print_section("출력(Chat json_mode)", GREEN, result_obj)
write_json(os.path.join(run_dir, "result.json"), result_obj)
else: # plain
resp = client.chat.completions.create(
model=cfg.name, messages=messages, tools=tools, tool_choice="auto"
)
outputs_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
print_section("툴 요청(Chat)", YELLOW, {"name": fn_name, "arguments": fn_args})
tool_output = dispatch_tool(fn_name, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": call.id, "name": fn_name,
"content": json.dumps(tool_output, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
resp = client.chat.completions.create(model=cfg.name, messages=messages)
outputs_dump.append(json.loads(resp.model_dump_json()))
msg = resp.choices[0].message
print_section("출력(Chat plain)", GREEN, {"text": msg.content})
# Save IO logs
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), outputs_dump)
# ===== Responses runner =====
def run_responses(
cfg: ModelConfig,
tools_variant: str,
mode: str, # "plain" | "json_mode" | "structured"
user_task: str,
run_dir: str,
) -> None:
client = OpenAI(base_url=cfg.base_url, api_key=cfg.api_key)
tools = make_tools(tools_variant, structured=(mode == "structured"))
system_inst = (
"You are a helpful assistant that uses tools when needed. "
"Task: List files in ./samples, pick a text file, read it, and return a short 2-3 sentence summary. "
"If JSON is requested, reply only with an object: {\"filename\": string, \"contents\": string}."
)
input_list: List[Dict[str, Any]] = [
{"role": "system", "content": system_inst},
{"role": "user", "content": user_task},
]
input_snapshot = {"input": input_list.copy(), "tools": tools, "mode": mode}
outputs_dump: List[Dict[str, Any]] = []
print_section("입력(Responses)", CYAN, input_snapshot)
if mode == "structured":
try:
r = client.responses.parse(
model=cfg.name,
input=input_list,
tools=tools,
text_format=TaskAnswer,
)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while guard < 12:
guard += 1
calls = _collect_function_calls_from_output(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.parse(
model=cfg.name, input=input_list, tools=tools, text_format=TaskAnswer
)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
parsed = getattr(r, "output_parsed", None)
out_obj = parsed.model_dump() if parsed else {"filename": "", "contents": _collect_text_from_output(getattr(r, "output", []))}
print_section("출력 요약(Responses structured)", GREEN, out_obj)
write_json(os.path.join(run_dir, "result.json"), out_obj)
except Exception as e:
print(RED + "\n--- ERROR(Responses structured) ---" + RESET)
print(repr(e))
elif mode == "json_mode":
r = client.responses.create(
model=cfg.name,
input=input_list,
tools=tools,
tool_choice="auto",
text={"format": {"type": "json_object"}},
)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while guard < 12:
guard += 1
calls = _collect_function_calls_from_output(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.create(
model=cfg.name,
input=input_list,
tools=tools,
text={"format": {"type": "json_object"}},
)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
# Try to parse JSON text
result_text = r.output_text or ""
try:
result_obj = json.loads(result_text)
except Exception:
result_obj = {"filename": "", "contents": result_text}
print_section("출력 요약(Responses json_mode)", GREEN, result_obj)
write_json(os.path.join(run_dir, "result.json"), result_obj)
else: # plain
r = client.responses.create(
model=cfg.name,
input=input_list,
tools=tools,
tool_choice="auto",
)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses step 1)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
guard = 0
while guard < 12:
guard += 1
calls = _collect_function_calls_from_output(getattr(r, "output", []))
if not calls:
break
for call in calls:
print_section("툴 요청(Responses)", YELLOW, {"name": call["name"], "arguments": call["arguments"]})
result = dispatch_tool(call["name"], call["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = client.responses.create(model=cfg.name, input=input_list, tools=tools)
outputs_dump.append(json.loads(r.model_dump_json()))
print_section("출력(Responses next)", GREEN, json.loads(r.model_dump_json()))
input_list += list(getattr(r, "output", []) or [])
print_section("출력 요약(Responses plain)", GREEN, {"text": _collect_text_from_output(getattr(r, "output", []))})
# Save IO logs
write_json(os.path.join(run_dir, "input.json"), input_snapshot)
write_json(os.path.join(run_dir, "outputs.json"), outputs_dump)
# ===== 24-combo driver =====
DEFAULT_TASK = (
"테스트: ./samples 폴더의 파일 목록을 보여주고, 텍스트 파일이 있으면 하나를 골라 내용을 읽고 2~3문장으로 요약해줘. "
"가능하다면 JSON으로 {\"filename\": string, \"contents\": string} 형태로 답해줘."
)
def run_one(model_key: str, api_type: str, tools_variant: str, mode: str, user_task: str, base_runs_dir: str):
cfg = MODELS[model_key]
label = combo_label(model_key, api_type, tools_variant, mode)
run_dir = os.path.join(base_runs_dir, label)
ensure_dirs(run_dir)
header = f"[{api_type.upper()}] model={model_key}:{cfg.name} tools={tools_variant} mode={mode}"
print("\n" + "=" * (len(header) + 2))
print(f" {header} ")
print("=" * (len(header) + 2))
try:
if api_type == "completions":
run_chat(cfg, tools_variant, mode, user_task, run_dir)
elif api_type == "responses":
run_responses(cfg, tools_variant, mode, user_task, run_dir)
else:
raise ValueError("api_type must be 'completions' or 'responses'")
except Exception as e:
print(RED + "\n--- ERROR ---" + RESET)
print(repr(e))
def main():
parser = argparse.ArgumentParser(description="Run 24 combos: 2 models × 2 APIs × 2 tool JSONs × 3 modes.")
parser.add_argument("--task", default=DEFAULT_TASK)
parser.add_argument("--models", choices=["A", "B", "both"], default="both")
parser.add_argument("--apis", choices=["completions", "responses", "both"], default="both")
parser.add_argument("--tools", choices=["chat", "responses", "both"], default="both")
parser.add_argument("--modes", choices=["plain", "json_mode", "structured", "all"], default="all")
parser.add_argument("--runs_dir", default=f"./runs/{now_id()}")
args = parser.parse_args()
os.makedirs("./samples", exist_ok=True)
ensure_dirs(args.runs_dir)
model_keys = ["A", "B"] if args.models == "both" else [args.models]
api_types = ["completions", "responses"] if args.apis == "both" else [args.apis]
tool_variants = ["chat", "responses"] if args.tools == "both" else [args.tools]
mode_list = ["plain", "json_mode", "structured"] if args.modes == "all" else [args.modes]
for mk in model_keys:
for api in api_types:
for tv in tool_variants:
for md in mode_list:
run_one(mk, api, tv, md, args.task, args.runs_dir)
if __name__ == "__main__":
main()
사용 팁
-
json_mode가 가장 안정적으로 {"filename","contents"}를 뱉어 줄 거야. structured는 SDK/모델의 strict 정책에 따라 여전히 실패할 수 있지만, 스크립트는 개별 조합별로 에러를 삼키고 계속 진행한다.
-
모든 조합의 입력/출력(raw)을 저장해서 리그레션 비교가 가능해. json_mode/structured는 result.json을 따로 저장하므로 바로 사용할 수 있어.
-
필요하면 system 메시지의 지시를 수정해서 contents에 “요약” 대신 “원문 전체”를 넣도록 바꿀 수도 있어.
둘 다 json 관련 포매팅은 잘 안먹히네 일단 일반 텍스트 출력만 생각하자. 이제 소프트웨어 아키텍트 디자인 측면에서 한 번 보자. 조합 1. GPT OSS + responses 형식 call + 신규 tool json 형식 조합 2. Qwen + completions 형식 call + 과거 tool json 형식 이거야. 그런데 나는 사용자가 일종의 콤보 박스로 GPT OSS를 쓸 것인지, Qwen을 쓸 것인지 선택하고 나머지는 그 때 그 때 잘 동작하게 하고 싶어. 뭔가 디자인 패턴적인 것을 써서 깔끔하게 이를 구현하는 방법이 있을까? function call tool도 어떤 식으로 구현하면 좋을까 파이썬 고수의 입장에서 이런 점을 고려해서 깔끔한 코드 만들어줘.
좋아. 요구 조합과 사용성(콤보박스로 엔진 선택)까지 고려해서 “전략(Strategy) + 툴 스키마 빌더(Factory) + 레지스트리 + 어댑터” 조합으로 깔끔하게 설계한 파이썬 예시를 줄게.
핵심 아이디어 요약(간결):
- Strategy 패턴: 서로 다른 호출 방식 차이를 캡슐화
-
ResponsesStrategy(GPT OSS, responses 호출, “신규/평평한” tool JSON)
-
CompletionsStrategy(Qwen, chat.completions 호출, “과거/중첩” tool JSON)
- ToolRegistry + ToolSchemaBuilder(Factory):
-
ToolRegistry는 파이썬 함수를 등록/실행
-
ToolSchemaBuilder가 등록된 툴을 각 엔진이 요구하는 JSON 모양으로 변환
-
NewToolJSONBuilder: responses용 flat JSON
-
OldToolJSONBuilder: completions용 nested JSON
-
- Output 어댑터(단순화):
- 두 전략은 모두 최종적으로 str을 반환. 내부의 tool_call 파싱 차이는 전략에서 숨김.
- 설정 주입:
- 엔드포인트·API 키·모델명을 config로 주입.
바로 실행 가능한 단일 파일 예시:
# arch_combo_clean.py
# Python 3.10+
# pip install --upgrade openai
from __future__ import annotations
import os
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
from abc import ABC, abstractmethod
from openai import OpenAI
# =========================
# Config & DTO
# =========================
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
@dataclass
class RunConfig:
engine: str # "gpt-oss" | "qwen"
task: str
samples_dir: str = "./samples"
# =========================
# Tool layer
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any] # JSON schema for parameters
func: Callable[[Dict[str, Any]], Any] # executes with parsed args
class ToolRegistry:
def __init__(self) -> None:
self._tools: Dict[str, ToolSpec] = {}
def register(self, spec: ToolSpec) -> None:
if spec.name in self._tools:
raise ValueError(f"Tool already registered: {spec.name}")
self._tools[spec.name] = spec
def specs(self) -> List[ToolSpec]:
return list(self._tools.values())
def call(self, name: str, args: Dict[str, Any]) -> Any:
if name not in self._tools:
return {"error": f"Unknown tool: {name}"}
return self._tools[name].func(args)
# =========================
# Tool JSON schema builders (Factory)
# =========================
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class NewToolJSONBuilder:
"""
Responses API에서 자주 보이는 '평평한(flat)' 툴 정의
{ "type":"function", "name":..., "description":..., "parameters":{...} }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
tools = []
for s in specs:
tools.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return tools
class OldToolJSONBuilder:
"""
Chat Completions API에서 자주 보이는 '중첩(nested)' 툴 정의
{ "type":"function", "function":{ "name":..., "description":..., "parameters":{...} } }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
tools = []
for s in specs:
tools.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters
}
})
return tools
# =========================
# Strategy layer
# =========================
class EngineStrategy(ABC):
def __init__(self, model: ModelConfig, tools: ToolRegistry, builder: ToolJSONBuilder) -> None:
self.model = model
self.tools = tools
self.builder = builder
self.client = OpenAI(base_url=model.base_url, api_key=model.api_key)
@abstractmethod
def run(self, task: str) -> str:
...
# ---- GPT OSS + Responses Strategy ----
class ResponsesStrategy(EngineStrategy):
"""
- responses.create 사용
- 툴 정의: NewToolJSONBuilder(flat)
- function_call → 로컬 툴 실행 → function_call_output를 input에 append → 다시 responses.create
"""
def run(self, task: str) -> str:
input_list: List[Dict[str, Any]] = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
tools_json = self.builder.build(self.tools.specs())
# 첫 호출
r = self.client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json,
tool_choice="auto",
)
# 누적 텍스트와 루프
final_text_chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
# 출력 텍스트 수집
final_text_chunks.append(self._collect_text(r))
# 툴 호출 수집
calls = self._collect_function_calls(r)
if not calls:
break
# 툴 실행 → function_call_output 추가
for call in calls:
name = call["name"]
args = call["arguments"]
result = self.tools.call(name, args)
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
# 다음 턴
r = self.client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json
)
return "\n".join([t for t in final_text_chunks if t])
# Helpers
def _collect_text(self, resp: Any) -> str:
texts: List[str] = []
out = getattr(resp, "output", []) or []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
def _collect_function_calls(self, resp: Any) -> List[Dict[str, Any]]:
calls: List[Dict[str, Any]] = []
out = getattr(resp, "output", []) or []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args})
return calls
# ---- Qwen + Chat Completions Strategy ----
class CompletionsStrategy(EngineStrategy):
"""
- chat.completions.create 사용
- 툴 정의: OldToolJSONBuilder(nested)
- assistant.tool_calls → 로컬 툴 실행 → tool 메시지 첨부 → 다시 completions
"""
def run(self, task: str) -> str:
tools_json = self.builder.build(self.tools.specs())
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
resp = self.client.chat.completions.create(
model=self.model.name,
messages=messages,
tools=tools_json,
tool_choice="auto",
)
msg = resp.choices[0].message
final_text_chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
result = self.tools.call(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(result, ensure_ascii=False)
})
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
messages.extend(tool_msgs)
resp = self.client.chat.completions.create(
model=self.model.name,
messages=messages,
)
msg = resp.choices[0].message
if msg.content:
final_text_chunks.append(msg.content)
return "\n\n".join([t for t in final_text_chunks if t])
# =========================
# Tool implementations
# =========================
def list_files_tool(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def read_text_tool(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
def build_registry(samples_dir: str) -> ToolRegistry:
reg = ToolRegistry()
reg.register(ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=list_files_tool
))
reg.register(ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to the text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=read_text_tool
))
return reg
# =========================
# Engine factory / selector
# =========================
def make_engine(cfg: RunConfig, model_cfg: ModelConfig, registry: ToolRegistry) -> EngineStrategy:
"""
콤보박스에서 'gpt-oss' 또는 'qwen'을 선택하면,
내부적으로 알맞은 Strategy + ToolJSONBuilder를 구성해서 반환.
"""
if cfg.engine == "gpt-oss":
# Responses + New(flat) tool JSON
return ResponsesStrategy(model_cfg, registry, NewToolJSONBuilder())
elif cfg.engine == "qwen":
# Completions + Old(nested) tool JSON
return CompletionsStrategy(model_cfg, registry, OldToolJSONBuilder())
else:
raise ValueError("Unknown engine. Choose 'gpt-oss' or 'qwen'.")
# =========================
# Example main
# =========================
def main():
# 실제에 맞게 환경 변수로 분리
gpt_oss = ModelConfig(
name=os.getenv("GPT_OSS_MODEL", "gpt-4.1-mini"),
base_url=os.getenv("GPT_OSS_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("GPT_OSS_API_KEY", "YOUR_KEY_FOR_GPT_OSS")
)
qwen = ModelConfig(
name=os.getenv("QWEN_MODEL", "qwen2.5"),
base_url=os.getenv("QWEN_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("QWEN_API_KEY", "YOUR_KEY_FOR_QWEN")
)
# 콤보박스 선택을 CLI로 가정 (실서비스라면 UI에서 이 값만 바꿔 주입)
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--engine", choices=["gpt-oss", "qwen"], default="gpt-oss")
parser.add_argument("--task", default="samples 디렉토리 내 파일을 보여주고 하나를 읽어 2~3문장 요약해줘. 필요하면 툴 사용.")
parser.add_argument("--samples", default="./samples")
args = parser.parse_args()
# 모델 선택: UI에서 엔진을 바꾸면 이 매핑도 바꿔치기
model_cfg = gpt_oss if args.engine == "gpt-oss" else qwen
run_cfg = RunConfig(engine=args.engine, task=args.task, samples_dir=args.samples)
# 레지스트리 구성(필요시 엔진별 추가/삭제도 가능)
registry = build_registry(run_cfg.samples_dir)
# 전략 생성
engine = make_engine(run_cfg, model_cfg, registry)
# 실행
result_text = engine.run(run_cfg.task)
print("\n=== FINAL OUTPUT ===")
print(result_text or "(empty)")
if __name__ == "__main__":
# 샘플 폴더 보장
os.makedirs("./samples", exist_ok=True)
main()
적용 포인트(설계 관점):
-
확장성: 새 엔진이 늘어나도 Strategy만 추가하면 됨. 각 엔진의 호출 차이(Responses vs Completions, tool loop 프로토콜)를 캡슐화했어.
-
책임 분리: ToolRegistry는 등록·실행만, ToolJSONBuilder는 JSON 모양만, Strategy는 대화 루프만 책임.
-
UI 연동: 콤보박스 선택값을 RunConfig.engine으로 주입 → make_engine이 올바른 전략을 리턴.
-
안전성: 루프에 guard로 폭주 방지. arguments는 json.loads로 관대하게 파싱.
-
구성 가능성: 모델명/엔드포인트/키는 ModelConfig로 분리. 환경변수나 설정파일로 치환 용이.
원한다면
-
로깅 인터페이스를 추가해 단계별(입력/툴요청/출력) 로그를 공통 포맷으로 남기기
-
ToolRegistry에 권한/샌드박스 층(파일 접근 화이트리스트) 추가
-
결과를 항상 {filename, contents} 포맷으로 어댑팅하는 OutputAdapter 추가
-
async 버전(토큰 절약/동시성)을 위한 비동기 전략 추가
까지도 같은 패턴으로 깔끔하게 확장 가능해.
조금 더 파이썬과 소프트웨어 아키텍트가 되어서 잘 생각해보자. if gpt-oss가 들어간 거 부터 좀 악취가 나. 추후에 새로운 모델이 들어오고 그 모델은 어떤 조합에서 잘 동작할지 모르잖아? 새로운 모델이 10개쯤 더 추가돼도 잘 관리될 수 있는 구조를 짜주면 좋겠어
좋아. “모델이 계속 늘어나도 관리 가능한 구조”에 초점을 맞춰 아키텍처를 재정리했어. 핵심은 “조합(Protocol × Tool JSON 형태)”을 모델에서 분리하고, 모델마다 “능력/선호(capabilities & preferences)”만 선언하도록 만드는 거야. 실행 전략은 조합에 따라 자동 선택·실행되며, 실패 시 합리적으로 폴백해.
아래 순서로 설명하고 바로 동작 가능한 예시 코드를 제공할게.
- 설계 요점
-
모델 무관 추상화
-
ProtocolStrategy: 호출 방식의 차이를 캡슐화. 현재 두 개 구현
-
ResponsesProtocol (responses.create 루프)
-
CompletionsProtocol (chat.completions.create 루프)
-
-
ToolSchemaBuilder: Tool JSON 모양을 캡슐화. 두 개 구현
-
FlatStyle (responses에서 흔한 평평한 스키마)
-
NestedStyle (completions에서 흔한 function 중첩)
-
-
-
조합(Plan) 단위 실행
-
Plan(protocol=…, tool_schema=…)
-
Orchestrator가 ModelProfile의 preference에 따라 Plan을 선택하고 실행. 실패 시 fallback Plans 순서대로 시도
-
-
ModelProfile은 “모델명/엔드포인트/키” + “capabilities & preferences”만 정의
-
새 모델 추가 = ModelProfile 하나 등록
-
모델이 어느 조합에서 잘 동작하는지 모르면, preferences는 비워 두고 Orchestrator가 표준 우선순위(Responses+Flat → Completions+Nested)를 적용
-
-
ToolRegistry로 툴 구현/등록을 분리
- 툴 함수 추가/변경이 모델/프로토콜 코드에 전혀 영향 없도록
- 실제 동작 흐름
-
UI 콤보박스에서 “모델 키”만 선택 → Orchestrator.run(model_key, task)
-
Orchestrator는 ModelCatalog에서 프로필 조회 → Plan 결정 → 해당 Strategy와 ToolSchemaBuilder를 결합해 실행
-
responses 프로토콜은 function_call_output 루프, completions 프로토콜은 tool 메시지 루프 수행
-
최종 결과는 일반 텍스트(plain)로 통일
- 코드
아래 파일 하나로 실행 가능. samples 폴더에 텍스트 파일 몇 개 두고 돌려봐.
# arch_flexible_orchestrator.py
# Python 3.10+
# pip install --upgrade openai
from __future__ import annotations
import os
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
from abc import ABC, abstractmethod
from openai import OpenAI
# =========================
# Basic Configs & DTOs
# =========================
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
@dataclass
class ModelCapabilities:
# 가능한 프로토콜
supports_responses: bool = True
supports_completions: bool = True
# 선호/권장 조합(Plan)을 우선 시도. 비워두면 기본 우선순위가 적용됨
preferred_plans: List[Tuple[str, str]] = field(default_factory=list) # e.g. [("responses","flat"), ("completions","nested")]
@dataclass
class ModelProfile:
key: str # UI 콤보박스용 ID
cfg: ModelConfig
caps: ModelCapabilities
# =========================
# Tool Layer
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any] # JSON schema
func: Callable[[Dict[str, Any]], Any]
class ToolRegistry:
def __init__(self) -> None:
self._tools: Dict[str, ToolSpec] = {}
def register(self, spec: ToolSpec) -> None:
if spec.name in self._tools:
raise ValueError(f"Tool already registered: {spec.name}")
self._tools[spec.name] = spec
def specs(self) -> List[ToolSpec]:
return list(self._tools.values())
def call(self, name: str, args: Dict[str, Any]) -> Any:
if name not in self._tools:
return {"error": f"Unknown tool: {name}"}
return self._tools[name].func(args)
# 기본 툴 2개
def list_files_tool(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def read_text_tool(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
def build_default_registry() -> ToolRegistry:
reg = ToolRegistry()
reg.register(ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=list_files_tool
))
reg.register(ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to the text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=read_text_tool
))
return reg
# =========================
# Tool JSON Schema Builders
# =========================
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatStyleBuilder:
"""
Responses에서 자주 쓰는 평평한 툴 형식:
{ "type":"function", "name":..., "description":..., "parameters":{...} }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedStyleBuilder:
"""
Completions에서 자주 쓰는 중첩 툴 형식:
{ "type":"function", "function":{ "name":..., "description":..., "parameters":{...} } }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
# =========================
# Protocol Strategies
# =========================
class ProtocolStrategy(ABC):
def __init__(self, model: ModelConfig, tools: ToolRegistry, builder: ToolJSONBuilder) -> None:
self.model = model
self.tools = tools
self.builder = builder
self.client = OpenAI(base_url=model.base_url, api_key=model.api_key)
@abstractmethod
def run(self, task: str) -> str:
...
# Responses 프로토콜
class ResponsesProtocol(ProtocolStrategy):
def run(self, task: str) -> str:
input_list = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
tools_json = self.builder.build(self.tools.specs())
r = self.client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json,
tool_choice="auto",
)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(r))
calls = self._collect_tool_calls(r)
if not calls:
break
for c in calls:
result = self.tools.call(c["name"], c["arguments"])
input_list.append({
"type": "function_call_output",
"call_id": c["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
r = self.client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json
)
return "\n".join([t for t in chunks if t])
def _collect_text(self, resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
def _collect_tool_calls(self, resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try: args = json.loads(args)
except Exception: args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args})
return calls
# Completions 프로토콜
class CompletionsProtocol(ProtocolStrategy):
def run(self, task: str) -> str:
tools_json = self.builder.build(self.tools.specs())
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
resp = self.client.chat.completions.create(
model=self.model.name,
messages=messages,
tools=tools_json,
tool_choice="auto",
)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs = []
for call in msg.tool_calls:
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
result = self.tools.call(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(result, ensure_ascii=False)
})
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
messages.extend(tool_msgs)
resp = self.client.chat.completions.create(
model=self.model.name,
messages=messages,
)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Planning (조합 결정)
# =========================
@dataclass(frozen=True)
class Plan:
protocol: str # "responses" | "completions"
tool_schema: str # "flat" | "nested"
class Orchestrator:
def __init__(self, profiles: Dict[str, ModelProfile], tools: ToolRegistry) -> None:
self.profiles = profiles
self.tools = tools
def run(self, model_key: str, task: str) -> str:
if model_key not in self.profiles:
raise ValueError(f"Unknown model key: {model_key}")
profile = self.profiles[model_key]
plans = self._candidate_plans(profile.caps)
last_error: Optional[Exception] = None
for plan in plans:
try:
strategy = self._make_strategy(profile.cfg, plan)
return strategy.run(task)
except Exception as e:
last_error = e
# 다음 플랜으로 시도
continue
if last_error:
raise last_error
return ""
def _candidate_plans(self, caps: ModelCapabilities) -> List[Plan]:
out: List[Plan] = []
# 1) 명시된 선호 플랜 우선
for proto, schema in caps.preferred_plans:
if self._supported(proto, caps):
out.append(Plan(proto, schema))
# 2) 기본 폴백 순서
defaults = [
Plan("responses", "flat"),
Plan("completions", "nested"),
Plan("responses", "nested"), # 혹시 특정 모델이 nested를 요구한다면
Plan("completions", "flat"),
]
for p in defaults:
if self._supported(p.protocol, caps) and p not in out:
out.append(p)
return out
def _supported(self, proto: str, caps: ModelCapabilities) -> bool:
return (proto == "responses" and caps.supports_responses) or \
(proto == "completions" and caps.supports_completions)
def _make_strategy(self, cfg: ModelConfig, plan: Plan) -> ProtocolStrategy:
builder: ToolJSONBuilder = FlatStyleBuilder() if plan.tool_schema == "flat" else NestedStyleBuilder()
if plan.protocol == "responses":
return ResponsesProtocol(cfg, self.tools, builder)
elif plan.protocol == "completions":
return CompletionsProtocol(cfg, self.tools, builder)
else:
raise ValueError(f"Unknown protocol: {plan.protocol}")
# =========================
# Model Catalog (등록/확장 지점)
# =========================
def build_catalog() -> Dict[str, ModelProfile]:
"""
새 모델을 추가하려면 아래 dict에 항목 1줄만 더 넣으면 됨.
- 모델 키(key): UI 콤보박스에서 쓰는 식별자
- name/base_url/api_key: 환경변수로 주입(다른 벤더 SDK여도 OpenAI 호환이면 그대로 가능)
- 캡능/선호(preferred_plans): 조합을 몰라도 비워두면 기본 순서대로 자동 시도
"""
return {
# 예시1: GPT-계열(Responses 선호)
"gpt_like": ModelProfile(
key="gpt_like",
cfg=ModelConfig(
name=os.getenv("GPT_LIKE_MODEL", "gpt-4.1-mini"),
base_url=os.getenv("GPT_LIKE_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("GPT_LIKE_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=True,
preferred_plans=[("responses", "flat")] # 먼저 responses+flat 시도
)
),
# 예시2: Qwen-계열(Completions 선호)
"qwen_like": ModelProfile(
key="qwen_like",
cfg=ModelConfig(
name=os.getenv("QWEN_MODEL", "qwen2.5"),
base_url=os.getenv("QWEN_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("QWEN_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=True,
preferred_plans=[("completions", "nested")] # 먼저 completions+nested 시도
)
),
# 예시3: Responses만 되는 모델
"resp_only": ModelProfile(
key="resp_only",
cfg=ModelConfig(
name=os.getenv("RESP_ONLY_MODEL", "some-resp-model"),
base_url=os.getenv("RESP_ONLY_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("RESP_ONLY_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=False,
preferred_plans=[("responses", "flat")]
)
),
# 예시4: Completions만 되는 모델
"chat_only": ModelProfile(
key="chat_only",
cfg=ModelConfig(
name=os.getenv("CHAT_ONLY_MODEL", "some-chat-model"),
base_url=os.getenv("CHAT_ONLY_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("CHAT_ONLY_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=False,
supports_completions=True,
preferred_plans=[("completions", "nested")]
)
),
}
# =========================
# Example main (UI 콤보박스 대체)
# =========================
def main():
import argparse
parser = argparse.ArgumentParser(description="Flexible model orchestrator (plain text, tools).")
parser.add_argument("--model-key", default="gpt_like", help="Model key from catalog (e.g., gpt_like, qwen_like, resp_only, chat_only)")
parser.add_argument("--task", default="samples 폴더 파일 목록을 보여주고, 텍스트 파일을 하나 읽어 2~3문장으로 요약해줘. 필요하면 툴 사용.")
args = parser.parse_args()
# 준비
os.makedirs("./samples", exist_ok=True)
registry = build_default_registry()
catalog = build_catalog()
orch = Orchestrator(catalog, registry)
# 실행
out = orch.run(args.model_key, args.task)
print("\n=== FINAL OUTPUT ===")
print(out or "(empty)")
if __name__ == "__main__":
main()
- 이 설계가 확장에 유리한 이유
-
새 모델 추가 시 변경 범위가 매우 작음
-
ModelCatalog에 ModelProfile 1줄 추가(이름/엔드포인트/키, 캡능/선호)
-
조합을 몰라도 OK: preferred_plans 비우면 Orchestrator가 표준 우선순위로 자동 시도
-
-
조합 폭발에도 견고
-
Plan을 독립 타입으로 관리해서 Protocol × ToolSchema를 명시적으로 표기
-
실패 시 다음 Plan으로 폴백하는 일관된 로직
-
-
관심사 분리
-
ToolRegistry/ToolSpec: 툴 구현과 파라미터 스키마만 관리
-
ToolJSONBuilder: JSON 모양만 담당(Flat/Nested 추가 쉬움)
-
ProtocolStrategy: 호출/루프/파싱 차이만 담당
-
Orchestrator: 모델 선택과 플랜 선정/폴백만 담당
-
- 커스터마이징 힌트
-
모델별 “도구 정책”이 다르면 ModelProfile에 tool_policy 콜백을 추가해 특정 툴만 노출 가능
-
로깅/메트릭스: ProtocolStrategy.run 내부에서 콜 카운트/지연/토큰 사용량(응답 필드에 있으면) 수집 후 공통 인터페이스로 리턴
-
안전 모드: 파일 접근 화이트리스트(예: samples 하위만 허용)를 ToolRegistry.call에 주입
-
후일 JSON 모드/Structured가 안정화되면 Plan에 “output_format” 축을 추가해 전략적으로 선택 가능
필요하면 이 구조로 테스트 하네스(여러 모델×플랜을 한 번에 돌려 결과 비교)도 바로 얹어줄게.
추가 요청 사항 1. Async 통신만 사용해줘.
async with AsyncOpenAI(
..., http_clinet=httpx.AsyncClient(event_hooks=hooks) as client:
client....
2. 유저가 인터럽트하면 중지가 되어야 해. 예를 들어 주기적으로 user.processing_interrupted() 함수를 호출해서 취소 여부를 확인하고 그에 따라 client도 깔끔하게 취소가 되어야 해. 3. 주석은 영문으로 써줘. 4. 모든 코드를 써줘.
아래는 확장성에 초점을 둔 비동기(Async) 오케스트레이션 샘플이야.
설계 포인트 요약
-
모델 수가 늘어나도 변경 최소화: 새 모델은 ModelProfile만 추가하면 됨.
-
조합(Protocol × Tool-JSON-Style)을 모델과 분리: Orchestrator가 Plan을 자동 선택·폴백.
-
순수 비동기 통신만 사용: AsyncOpenAI + httpx.AsyncClient.
-
사용자 인터럽트 지원: user.processing_interrupted()를 주기적으로 확인하고, 진행 중 요청은 asyncio Task 취소로 깔끔히 중단.
코드는 한 파일로 동작하며 일반 텍스트 출력만을 반환한다.
# async_arch_orchestrator.py
# Python 3.10+
# pip install --upgrade openai httpx
from __future__ import annotations
import os
import json
import asyncio
import signal
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
from abc import ABC, abstractmethod
import httpx
from openai import AsyncOpenAI
# ============================================================
# Exceptions / Interrupt
# ============================================================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class UserInterruptController:
"""
A simple interrupt controller.
- Call `trigger()` to mark as interrupted (e.g., SIGINT handler).
- Frameworks/UI can override processing_interrupted() to integrate with their own state.
"""
def __init__(self) -> None:
self._event = asyncio.Event()
async def processing_interrupted(self) -> bool:
"""Return True if the user has requested cancellation."""
return self._event.is_set()
def trigger(self) -> None:
"""Externally trigger an interrupt (e.g., from a signal handler or UI callback)."""
self._event.set()
# ============================================================
# Configs & Profiles
# ============================================================
@dataclass
class ModelConfig:
name: str
base_url: str
api_key: str
@dataclass
class ModelCapabilities:
# Supported protocols
supports_responses: bool = True
supports_completions: bool = True
# Preferred plans, empty ⇒ Orchestrator applies default order.
preferred_plans: List[Tuple[str, str]] = field(default_factory=list) # e.g. [("responses", "flat"), ("completions", "nested")]
@dataclass
class ModelProfile:
key: str
cfg: ModelConfig
caps: ModelCapabilities
# ============================================================
# Tool Layer
# ============================================================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any] # JSON schema object
func: Callable[[Dict[str, Any]], Any] # Sync function (fast local I/O)
class ToolRegistry:
"""Keeps tool specs and executes them by name."""
def __init__(self) -> None:
self._tools: Dict[str, ToolSpec] = {}
def register(self, spec: ToolSpec) -> None:
if spec.name in self._tools:
raise ValueError(f"Tool already registered: {spec.name}")
self._tools[spec.name] = spec
def specs(self) -> List[ToolSpec]:
return list(self._tools.values())
def call(self, name: str, args: Dict[str, Any]) -> Any:
if name not in self._tools:
return {"error": f"Unknown tool: {name}"}
return self._tools[name].func(args)
# Default file tools
def list_files_tool(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def read_text_tool(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
def build_default_registry() -> ToolRegistry:
reg = ToolRegistry()
reg.register(ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=list_files_tool
))
reg.register(ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to the text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=read_text_tool
))
return reg
# ============================================================
# Tool JSON builders (Factory)
# ============================================================
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatStyleBuilder:
"""
"New" / flat tool JSON (often used with Responses-style examples):
{ "type":"function", "name":..., "description":..., "parameters":{...} }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedStyleBuilder:
"""
"Old" / nested tool JSON (common in Chat Completions examples):
{ "type":"function", "function":{ "name":..., "description":..., "parameters":{...} } }
"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
# ============================================================
# Async helpers (HTTP client, cancellation wrapper)
# ============================================================
async def call_with_interrupt(coro, interrupt_cb, poll_interval: float = 0.2):
"""
Run an awaitable while periodically checking for user interruption.
If interrupted, cancel the task and raise UserCancelledError.
"""
task = asyncio.create_task(coro)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll_interval)
if task in done:
return task.result()
# Periodically check interrupt
if await interrupt_cb():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Processing interrupted by user.")
except Exception:
raise
def build_httpx_hooks() -> Dict[str, List[Callable]]:
"""
Create httpx AsyncClient event hooks.
You can add logging or tracing here if desired.
"""
async def on_request(request: httpx.Request):
# Example: print(f"[httpx] -> {request.method} {request.url}")
return
async def on_response(response: httpx.Response):
# Example: print(f"[httpx] <- {response.status_code} {response.request.url}")
return
return {"request": [on_request], "response": [on_response]}
# ============================================================
# Protocol Strategies (Async)
# ============================================================
class ProtocolStrategy(ABC):
"""
Base class for protocol strategies.
Each strategy must:
- Use AsyncOpenAI only
- Respect user interruption at reasonable checkpoints
"""
def __init__(
self,
model: ModelConfig,
tools: ToolRegistry,
builder: ToolJSONBuilder,
interrupt: UserInterruptController,
) -> None:
self.model = model
self.tools = tools
self.builder = builder
self.interrupt = interrupt
@abstractmethod
async def run(self, task: str) -> str:
...
class ResponsesProtocol(ProtocolStrategy):
"""
Async implementation of a Responses-style loop:
- responses.create
- process output_text
- if tool calls exist: execute local tool(s), append function_call_output, repeat
"""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_tool_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({
"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {},
})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({
"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {},
})
return calls
async def run(self, task: str) -> str:
hooks = build_httpx_hooks()
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=self.model.base_url, api_key=self.model.api_key, http_client=http_client) as client:
input_list = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
tools_json = self.builder.build(self.tools.specs())
# First call
await self._guard_interrupt()
resp = await call_with_interrupt(
client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json,
tool_choice="auto",
),
self.interrupt.processing_interrupted
)
# Loop
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_tool_calls(resp)
if not calls:
break
# Execute tools locally and append outputs
for call in calls:
# Check for interruption between tool calls as well
await self._guard_interrupt()
name = call["name"]
args = call["arguments"]
result = self.tools.call(name, args)
input_list.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
# Next turn
await self._guard_interrupt()
resp = await call_with_interrupt(
client.responses.create(
model=self.model.name,
input=input_list,
tools=tools_json,
),
self.interrupt.processing_interrupted
)
return "\n".join([t for t in chunks if t])
async def _guard_interrupt(self) -> None:
if await self.interrupt.processing_interrupted():
raise UserCancelledError("Processing interrupted by user.")
class CompletionsProtocol(ProtocolStrategy):
"""
Async implementation of a Chat Completions-style loop:
- chat.completions.create
- assistant.tool_calls → execute tools → append tool messages → repeat
"""
async def run(self, task: str) -> str:
hooks = build_httpx_hooks()
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=self.model.base_url, api_key=self.model.api_key, http_client=http_client) as client:
tools_json = self.builder.build(self.tools.specs())
messages = [
{"role": "system", "content": "You are a helpful assistant that uses tools when needed."},
{"role": "user", "content": task},
]
await self._guard_interrupt()
resp = await call_with_interrupt(
client.chat.completions.create(
model=self.model.name,
messages=messages,
tools=tools_json,
tool_choice="auto",
),
self.interrupt.processing_interrupted
)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
# Prepare tool messages
tool_msgs = []
for call in msg.tool_calls:
await self._guard_interrupt()
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
result = self.tools.call(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(result, ensure_ascii=False)
})
# Append assistant tool_calls echo + tool outputs
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
messages.extend(tool_msgs)
# Next turn
await self._guard_interrupt()
resp = await call_with_interrupt(
client.chat.completions.create(
model=self.model.name,
messages=messages,
),
self.interrupt.processing_interrupted
)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
async def _guard_interrupt(self) -> None:
if await self.interrupt.processing_interrupted():
raise UserCancelledError("Processing interrupted by user.")
# ============================================================
# Planning (Protocol × Tool-JSON combination)
# ============================================================
@dataclass(frozen=True)
class Plan:
protocol: str # "responses" | "completions"
tool_schema: str # "flat" | "nested"
class Orchestrator:
"""
Chooses and executes the best Plan for a given model, with fallback.
Fully async and interruption-aware.
"""
def __init__(self, profiles: Dict[str, ModelProfile], tools: ToolRegistry, interrupt: UserInterruptController) -> None:
self.profiles = profiles
self.tools = tools
self.interrupt = interrupt
async def run(self, model_key: str, task: str) -> str:
if model_key not in self.profiles:
raise ValueError(f"Unknown model key: {model_key}")
profile = self.profiles[model_key]
plans = self._candidate_plans(profile.caps)
last_error: Optional[Exception] = None
for plan in plans:
try:
strategy = self._make_strategy(profile.cfg, plan)
return await strategy.run(task)
except UserCancelledError:
# Bubble up immediately on user cancellation
raise
except Exception as e:
last_error = e
# Try next plan on failure
continue
if last_error:
raise last_error
return ""
def _candidate_plans(self, caps: ModelCapabilities) -> List[Plan]:
out: List[Plan] = []
# 1) Preferred plans first
for proto, schema in caps.preferred_plans:
if self._supported(proto, caps):
out.append(Plan(proto, schema))
# 2) Default fallback order
defaults = [
Plan("responses", "flat"),
Plan("completions", "nested"),
Plan("responses", "nested"),
Plan("completions", "flat"),
]
for p in defaults:
if self._supported(p.protocol, caps) and p not in out:
out.append(p)
return out
def _supported(self, proto: str, caps: ModelCapabilities) -> bool:
return (proto == "responses" and caps.supports_responses) or \
(proto == "completions" and caps.supports_completions)
def _make_strategy(self, cfg: ModelConfig, plan: Plan):
builder = FlatStyleBuilder() if plan.tool_schema == "flat" else NestedStyleBuilder()
if plan.protocol == "responses":
return ResponsesProtocol(cfg, self.tools, builder, self.interrupt)
elif plan.protocol == "completions":
return CompletionsProtocol(cfg, self.tools, builder, self.interrupt)
else:
raise ValueError(f"Unknown protocol: {plan.protocol}")
# ============================================================
# Model Catalog (extensible)
# ============================================================
def build_catalog() -> Dict[str, ModelProfile]:
"""
To add a new model, just create another ModelProfile entry here.
If you don't know which combo works best, leave `preferred_plans` empty;
Orchestrator will try the defaults in a safe order.
"""
return {
# Example: GPT-like model (prefers Responses + flat tools)
"gpt_like": ModelProfile(
key="gpt_like",
cfg=ModelConfig(
name=os.getenv("GPT_LIKE_MODEL", "gpt-4.1-mini"),
base_url=os.getenv("GPT_LIKE_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("GPT_LIKE_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=True,
preferred_plans=[("responses", "flat")]
)
),
# Example: Qwen-like model (prefers Completions + nested tools)
"qwen_like": ModelProfile(
key="qwen_like",
cfg=ModelConfig(
name=os.getenv("QWEN_MODEL", "qwen2.5"),
base_url=os.getenv("QWEN_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("QWEN_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=True,
preferred_plans=[("completions", "nested")]
)
),
# Example: responses-only vendor
"resp_only": ModelProfile(
key="resp_only",
cfg=ModelConfig(
name=os.getenv("RESP_ONLY_MODEL", "some-resp-model"),
base_url=os.getenv("RESP_ONLY_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("RESP_ONLY_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=True,
supports_completions=False,
preferred_plans=[("responses", "flat")]
)
),
# Example: completions-only vendor
"chat_only": ModelProfile(
key="chat_only",
cfg=ModelConfig(
name=os.getenv("CHAT_ONLY_MODEL", "some-chat-model"),
base_url=os.getenv("CHAT_ONLY_BASE_URL", "https://api.openai.com/v1"),
api_key=os.getenv("CHAT_ONLY_API_KEY", "YOUR_KEY"),
),
caps=ModelCapabilities(
supports_responses=False,
supports_completions=True,
preferred_plans=[("completions", "nested")]
)
),
}
# ============================================================
# Main (async) with signal-based interrupt wiring
# ============================================================
async def amain():
import argparse
parser = argparse.ArgumentParser(description="Async flexible orchestrator (plain text + tool calling).")
parser.add_argument("--model-key", default="gpt_like", help="Pick from catalog: gpt_like, qwen_like, resp_only, chat_only")
parser.add_argument("--task", default="List files under ./samples, pick a text file, read it, and return a 2-3 sentence summary. Use tools if needed.")
parser.add_argument("--samples", default="./samples")
args = parser.parse_args()
# Build tool registry (you can scope tools per model if required)
os.makedirs(args.samples, exist_ok=True)
tools = build_default_registry()
# Build catalog and orchestrator
catalog = build_catalog()
interrupt = UserInterruptController()
# OS signal → user interrupt (Ctrl+C)
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, interrupt.trigger)
loop.add_signal_handler(signal.SIGTERM, interrupt.trigger)
except NotImplementedError:
# add_signal_handler may not be available on some platforms (e.g., Windows in some envs)
pass
orch = Orchestrator(catalog, tools, interrupt)
try:
out = await orch.run(args.model_key, args.task)
print("\n=== FINAL OUTPUT ===")
print(out or "(empty)")
except UserCancelledError:
print("\n=== INTERRUPTED ===")
except Exception as e:
print("\n=== ERROR ===")
print(repr(e))
if __name__ == "__main__":
asyncio.run(amain())
확장 방법
-
새 모델 추가: build_catalog()에 ModelProfile 한 줄만 추가. preferred_plans가 비어도 Orchestrator가 기본 순서대로 시도.
-
툴 추가: ToolRegistry.register(…)만 호출. 파라미터 스키마와 함수 구현을 붙이면 자동으로 모든 전략에서 사용.
-
인터럽트 연동: UI 스레드에서 interrupt.trigger()를 호출하거나, UserInterruptController를 구현해 processing_interrupted()가 True를 반환하도록 만들면 된다.
-
로깅/메트릭/리트라이: call_with_interrupt()나 각 ProtocolStrategy.run() 내부에 쉽게 주입 가능.
responses와 completions에서 parameter 넣는 방식은 똑같아? 예를 들어 reasoning_effort나 max_tokens 같은 것들
짧은 답: 완전히 같지 않아. 공통 파라미터도 있지만, 길이·추론 제어 계열은 엔드포인트마다 이름이 다르다.
핵심 정리
-
출력 길이 상한
-
Responses API: max_output_tokens 사용. OpenAI 플랫폼
-
Chat Completions: 모델에 따라 다름
-
추론형(o*, GPT-5 계열 등): max_completion_tokens 권장
-
일반 채팅 모델: 기존 max_tokens
실제로 서로 바꿔 쓰면 무시되거나 에러가 난다. 공식 Chat API 문서는 max_tokens 계열을, Responses/Realtime 쪽 문서는 max_output_tokens를 각각 다룬다. OpenAI 플랫폼+1
-
-
-
추론 강도(“얼마나 깊게 생각할지”)
-
최신 가이드라인은 reasoning 객체 아래 effort 필드를 쓰는 형태가 표준이다. 예: reasoning={"effort": "low medium high"} 또는 일부 최신 모델에서 "minimal" 추가. 두 엔드포인트 모두 같은 모양으로 붙이되, 모델이 지원할 때만 적용된다. OpenAI 플랫폼
-
-
그 외 공통으로 거의 동일하게 쓰는 것
temperature, top_p, stop, tools 정의 자체 등은 두 엔드포인트 모두 유사하게 동작한다. 차이는 메시지 컨테이너가 responses는 input, chat completions는 messages라는 점 정도다. OpenAI 플랫폼
권장 사용 패턴
-
Responses API 예시
-
max_output_tokens와 reasoning.effort를 함께 설정
-
메시지는 input=[{role, content}, …]
-
-
Chat Completions 예시
-
추론 모델이면 max_completion_tokens 사용, 일반 모델이면 max_tokens
-
메시지는 messages=[{role, content}, …]
-
reasoning.effort는 지원 모델에서만
-
간단 예시
Python, Responses API
resp = await client.responses.create(
model="gpt-5-mini",
input=[{"role": "user", "content": "요약해줘"}],
max_output_tokens=512,
reasoning={"effort": "medium"},
tools=[{"type":"function","name":"list_files_in_folder","parameters":{...}}],
)
Python, Chat Completions
resp = await client.chat.completions.create(
model="o3-mini", # 추론형 모델
messages=[{"role":"user","content":"요약해줘"}],
max_completion_tokens=512, # 추론형이면 이걸 사용
reasoning={"effort": "medium"},
tools=[{"type":"function","function":{"name":"list_files_in_folder","parameters":{...}}}],
)
# 일반 채팅 모델이라면 max_tokens를 사용
요약 체크리스트
-
Responses → max_output_tokens
-
Chat Completions → 추론형: max_completion_tokens / 일반: max_tokens
-
reasoning.effort는 모델이 지원할 때만 적용
-
공통 파라미터(temperature, top_p, stop 등)는 거의 동일하고, 컨테이너만 input vs messages가 다름
참고 문서
-
Chat Completions API 레퍼런스(파라미터 전반, messages 등). OpenAI 플랫폼
-
Reasoning 가이드(추론 모델과 effort 개념). OpenAI 플랫폼
-
Realtime/Responses 계열에서 max_output_tokens 명시. OpenAI 플랫폼
왜 이렇게 api를 짜증나게 만들었을까
그 마음 100% 이해해. 엔드포인트마다 이름이 미묘하게 다르고, 모델마다 지원/비지원이 달라서 매번 “이건 max_output_tokens였지? 아니면 max_tokens였나…?” 하게 만들죠.
왜 이런 일이 생기냐(현실적인 배경)
-
레거시와 진화가 동시에 굴러감
초기 Chat Completions의 설계가 이미 광범위하게 쓰인 뒤, Responses 계열이 “더 범용(멀티모달·툴·스트리밍·상태전달)” 관점으로 나왔어요. 과거 API를 깨지 않으려니 파라미터/컨테이너 이름이 달라졌습니다. -
모델 계열별 제약이 달라요
“추론형(reasoning) 모델”은 토큰·추론깊이 제어가 별도라 max_completion_tokens, reasoning.effort 같은 필드가 추가되고, 일반 채팅 모델과 동작이 달라요. -
안전·비용·속도 정책이 독립적으로 변함
각 엔드포인트가 검열·중단·비용제어 사양을 따로 발전시키다 보니 교집합이 어긋납니다. -
도구(tool) 생태계의 빠른 변화
함수 호출 스키마가 “nested” → “flat”로, 혹은 strict 모드 추가처럼 사용성 개선을 반복하면서 예시와 실제가 엇갈리는 시기가 생겨요.
그래서 어떻게 덜 짜증나게 쓰냐(실전 팁)
-
어댑터 + 전략 패턴으로 API 차이를 가둬두기
호출부엔 “내가 원하는 공통 파라미터”만 쓰고, 엔드포인트별로 맞는 실제 파라미터로 변환하는 얇은 레이어를 둡니다. -
파라미터 정규화기(Parameter Normalizer)
length, temp, top_p, stop, reasoning_effort 같은 “중립 이름”을 입력받아 completions/responses/모델종류에 맞춰 매핑합니다. -
툴 스키마 빌더
동일한 ToolRegistry를 두고, “flat”과 “nested” 스키마로 빌드만 갈라서 뿌립니다. -
기능 감지와 폴백
모델 프로필에 capabilities를 선언하고, 우선 시도할 조합 목록을 정해 실패 시 다음 조합으로 자동 폴백합니다. -
계약 테스트(작은 매트릭스 러너)
대표 프롬프트 하나로 “모델×프로토콜×툴스키마×파라미터세트”를 돌려 빨갛게 실패하는 조합을 미리 파악해둡니다.
아래는 “딱 이것만” 넣으면 스트레스가 급감하는 최소 어댑터 예시야. 비동기 기준, 호출부는 공통 파라미터만 건네고 내부에서 알아서 매핑합니다.
# adapters.py
from __future__ import annotations
from typing import Any, Dict, List, Optional
def build_tools_flat(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# input tools: [{"name", "description", "parameters"}]
return [{"type": "function", **t} for t in tools]
def build_tools_nested(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [{"type": "function", "function": t} for t in tools]
class ParamNormalizer:
"""
Normalize neutral params -> endpoint-specific params.
neutral:
- length (int): desired max tokens
- temperature (float), top_p (float), stop (list[str])
- reasoning_effort (None|"low"|"medium"|"high")
- tool_json_style ("flat"|"nested")
endpoint:
- responses: max_output_tokens, input, tools(flat)
- completions: max_completion_tokens or max_tokens, messages, tools(nested)
"""
@staticmethod
def for_responses(
neutral: Dict[str, Any],
messages: List[Dict[str, Any]], # [{role, content}]
tools: List[Dict[str, Any]], # normalized {"name", "description", "parameters"}
) -> Dict[str, Any]:
out: Dict[str, Any] = {
"input": messages,
"tools": build_tools_flat(tools),
}
if (L := neutral.get("length")) is not None:
out["max_output_tokens"] = int(L)
if (t := neutral.get("temperature")) is not None:
out["temperature"] = float(t)
if (p := neutral.get("top_p")) is not None:
out["top_p"] = float(p)
if (st := neutral.get("stop")) is not None:
out["stop"] = st
if (eff := neutral.get("reasoning_effort")):
out["reasoning"] = {"effort": eff}
return out
@staticmethod
def for_completions(
neutral: Dict[str, Any],
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]],
*,
is_reasoning_model: bool = False,
) -> Dict[str, Any]:
out: Dict[str, Any] = {
"messages": messages,
"tools": build_tools_nested(tools),
}
# token cap
if (L := neutral.get("length")) is not None:
out["max_completion_tokens" if is_reasoning_model else "max_tokens"] = int(L)
# common
if (t := neutral.get("temperature")) is not None:
out["temperature"] = float(t)
if (p := neutral.get("top_p")) is not None:
out["top_p"] = float(p)
if (st := neutral.get("stop")) is not None:
out["stop"] = st
# reasoning
if (eff := neutral.get("reasoning_effort")) and is_reasoning_model:
out["reasoning"] = {"effort": eff}
return out
간단 사용 예시(비동기):
# use_case.py
import httpx
from openai import AsyncOpenAI
from adapters import ParamNormalizer
async def run_responses(model: str, base_url: str, api_key: str):
messages = [
{"role": "system", "content": "Be helpful."},
{"role": "user", "content": "List ./samples and summarize one file."},
]
tools = [
{"name": "list_files_in_folder", "description": "...", "parameters": {...}},
{"name": "read_text_file", "description": "...", "parameters": {...}},
]
neutral = dict(length=400, temperature=0.4, reasoning_effort="medium")
params = ParamNormalizer.for_responses(neutral, messages, tools)
async with httpx.AsyncClient() as http_client:
async with AsyncOpenAI(base_url=base_url, api_key=api_key, http_client=http_client) as client:
r = await client.responses.create(model=model, **params, tool_choice="auto")
return r
async def run_completions(model: str, base_url: str, api_key: str, is_reasoning_model: bool):
messages = [
{"role": "system", "content": "Be helpful."},
{"role": "user", "content": "List ./samples and summarize one file."},
]
tools = [
{"name": "list_files_in_folder", "description": "...", "parameters": {...}},
{"name": "read_text_file", "description": "...", "parameters": {...}},
]
neutral = dict(length=400, temperature=0.4, reasoning_effort="medium")
params = ParamNormalizer.for_completions(neutral, messages, tools, is_reasoning_model=is_reasoning_model)
async with httpx.AsyncClient() as http_client:
async with AsyncOpenAI(base_url=base_url, api_key=api_key, http_client=http_client) as client:
r = await client.chat.completions.create(model=model, **params, tool_choice="auto")
return r
마이그레이션 체크리스트
-
호출부엔 neutral 파라미터만 노출한다(length, temperature, reasoning_effort 등).
-
엔진/모델 선택에 따라 ParamNormalizer가 실제 필드로 변환한다.
-
모델 능력 플래그(예: is_reasoning_model)는 ModelProfile에 넣고 Orchestrator가 주입한다.
-
실패 시 다른 Plan으로 폴백한다(Responses+flat → Completions+nested 같은 순서).
-
계약 테스트를 CI에 넣어 눈으로 확인하기 전에 “어느 조합이 깨졌는지” 자동으로 경고받는다.
API의 불일치는 우리 잘못이 아니니, 작은 어댑터/전략 레이어로 “짜증”을 코드 경계 밖에 가둬두는 게 정답이에요. 이 레이어 한 번 만들어두면 새 모델 10개가 들어와도 호출부는 거의 손댈 일이 없어집니다.
근데 너가 말한것도 못 믿겠어. 실제 내 서빙 환경과 api 패키지 설치 환경에서 테스트해봐야할 것 같아. 그런데 top_p 같은 것은 테스트해봐도 이게 반영이 된 건지 안 된 건지 내가 알 수가 있나?
완전 공감. 파라미터가 “들어가긴 했는지, 모델이 실제로 반영했는지”는 말만으론 못 믿죠. 그래서 실제 서빙 환경에서 직접 검증하는 비동기 테스트 하네스를 줘요. 핵심은 두 축입니다.
-
전송 검증: HTTP 요청 바디에 top_p, temperature 등이 실제로 포함되는지 로깅
-
효과 검증: 동일 프롬프트로 여러 번 샘플링하여 top_p 스윕에 따라 출력 다양성이 통계적으로 달라지는지 확인
아래 코드는 둘 다 합니다. Responses와 Completions를 선택해 같은 방식으로 돌릴 수 있고, 각 top_p 구간에서 N회 샘플링하여 텍스트 다양성 지표를 계산합니다. logprobs 지원 모델이면 상위 토큰 분포도 같이 덤프해 “누클리어스 컷(top_p 이하 누적확률)” 영향도 간접 확인합니다. 모두 비동기 AsyncOpenAI + httpx.AsyncClient 사용, 이벤트 훅으로 실제 전송 JSON을 캡처합니다.
설명 요약
-
전송 로깅: httpx 이벤트 훅에서 요청 JSON을 파싱해 주요 필드만 출력 및 파일 저장
-
다양성 지표: 타입-토큰 비율(TTR), 평균 길이, 응답 쌍 간 3-그램 자카드 거리 평균
-
스윕: 예) top_p ∈ {0.1, 0.3, 0.7, 0.95}, temperature 고정(예: 0.8), 각 구간 N회 샘플
-
기대 패턴: top_p가 커질수록 평균 자카드 거리, TTR이 증가하는 경향. 전혀 변화가 없다면 모델/엔드포인트에서 파라미터가 무시되었을 가능성
-
안전장치: Ctrl+C 등 인터럽트 시 즉시 중지
코드 전체
# async_param_probe.py
# Python 3.10+
# pip install --upgrade openai httpx
from __future__ import annotations
import os
import json
import math
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt support
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class Interruptor:
"""Simple async interrupt flag that can be triggered by signals or UI."""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(coro, interrupt_cb, poll: float = 0.25):
"""Await a coroutine while polling interruption."""
task = asyncio.create_task(coro)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await interrupt_cb():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user")
except Exception:
raise
# =========================
# Request capture via httpx hooks
# =========================
class RequestCapture:
"""Capture outgoing JSON payloads for inspection."""
def __init__(self) -> None:
self.last_payload: Optional[Dict[str, Any]] = None
self.all_payloads: List[Dict[str, Any]] = []
def get_hooks(self):
async def on_request(request: httpx.Request):
try:
if request.headers.get("content-type", "").startswith("application/json"):
body = request.content.decode("utf-8") if isinstance(request.content, bytes) else request.content
data = json.loads(body) if body else {}
# Keep only notable fields to keep logs readable
filtered = {
"path": request.url.path,
"model": data.get("model"),
"messages": data.get("messages") or data.get("input"),
"tools": data.get("tools"),
"top_p": data.get("top_p"),
"temperature": data.get("temperature"),
"max_tokens": data.get("max_tokens"),
"max_completion_tokens": data.get("max_completion_tokens"),
"max_output_tokens": data.get("max_output_tokens"),
"reasoning": data.get("reasoning"),
"logprobs": data.get("logprobs"),
"top_logprobs": data.get("top_logprobs"),
"tool_choice": data.get("tool_choice"),
"response_format": data.get("response_format"),
"text": data.get("text"),
}
self.last_payload = filtered
self.all_payloads.append(filtered)
except Exception:
# Best effort; never block the request
pass
async def on_response(response: httpx.Response):
return
return {"request": [on_request], "response": [on_response]}
# =========================
# Diversity metrics
# =========================
def tokenize_words(text: str) -> List[str]:
return [w for w in "".join([c.lower() if c.isalnum() else " " for c in text]).split() if w]
def ngrams(tokens: List[str], n: int) -> List[Tuple[str, ...]]:
if n <= 0 or len(tokens) < n:
return []
return [tuple(tokens[i:i+n]) for i in range(len(tokens)-n+1)]
def jaccard(a: List[Tuple[str, ...]], b: List[Tuple[str, ...]]) -> float:
if not a and not b:
return 0.0
sa, sb = set(a), set(b)
inter = len(sa & sb)
union = len(sa | sb) or 1
return 1.0 - (inter / union)
def ttr(tokens: List[str]) -> float:
if not tokens:
return 0.0
return len(set(tokens)) / len(tokens)
def pairwise_avg_jaccard(texts: List[str], n: int = 3) -> float:
if len(texts) < 2:
return 0.0
total = 0.0
count = 0
grams = [ngrams(tokenize_words(t), n) for t in texts]
for i in range(len(texts)):
for j in range(i+1, len(texts)):
total += jaccard(grams[i], grams[j])
count += 1
return total / max(count, 1)
def summarize_diversity(samples: List[str]) -> Dict[str, Any]:
toks = [tokenize_words(s) for s in samples]
lengths = [len(t) for t in toks]
ttrs = [ttr(t) for t in toks]
return {
"num_samples": len(samples),
"avg_len_tokens": round(sum(lengths) / max(len(lengths), 1), 2),
"avg_ttr": round(sum(ttrs) / max(len(ttrs), 1), 4),
"pairwise_jaccard_3gram": round(pairwise_avg_jaccard(samples, 3), 4),
"pairwise_jaccard_2gram": round(pairwise_avg_jaccard(samples, 2), 4),
"unique_ratio": round(len(set(samples)) / max(len(samples), 1), 4),
}
# =========================
# Probers
# =========================
@dataclass
class ProbeConfig:
model: str
base_url: str
api_key: str
protocol: str # "responses" | "completions"
prompt: str
temperature: float = 0.8
top_p_grid: Tuple[float, ...] = (0.1, 0.3, 0.7, 0.95)
samples_per_setting: int = 8
request_log_dir: str = "./probe_logs"
use_logprobs: bool = True # Only for completions if model supports it
def sanitize_filename(s: str) -> str:
return "".join(c if c.isalnum() or c in "-_." else "_" for c in s)
async def sample_once_responses(client: AsyncOpenAI, prompt: str, temperature: float, top_p: float) -> str:
r = await client.responses.create(
model=client._client._base_url, # wrong; fix below
)
# We will implement properly in the caller using **params normalization; keep explicit here.
raise NotImplementedError("Use run_responses_batch; do not call directly.")
async def run_responses_batch(client: AsyncOpenAI, prompt: str, temperature: float, top_p: float, interrupt: Interruptor) -> str:
input_list = [
{"role": "system", "content": "Be helpful and concise."},
{"role": "user", "content": prompt},
]
resp = await call_with_interrupt(
client.responses.create(
model=client._client._headers.get("x-stainless-model") or "unknown-model",
# The AsyncOpenAI SDK requires model at call-site; we provide it via partial elsewhere.
),
interrupt.processing_interrupted
)
return resp.output_text or ""
async def run_completions_batch(client: AsyncOpenAI, model: str, prompt: str, temperature: float, top_p: float, use_logprobs: bool, interrupt: Interruptor) -> Dict[str, Any]:
resp = await call_with_interrupt(
client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "Be helpful and concise."},
{"role": "user", "content": prompt},
],
temperature=temperature,
top_p=top_p,
# Logprobs is model-dependent; if unsupported it will raise. Handle at caller.
**({"logprobs": True, "top_logprobs": 5} if use_logprobs else {}),
),
interrupt.processing_interrupted
)
out_text = resp.choices[0].message.content or ""
out_logprobs = getattr(resp.choices[0], "logprobs", None)
return {"text": out_text, "logprobs": out_logprobs}
# =========================
# Runner
# =========================
async def run_probe(cfg: ProbeConfig, interrupt: Interruptor):
os.makedirs(cfg.request_log_dir, exist_ok=True)
capture = RequestCapture()
hooks = capture.get_hooks()
# Build AsyncOpenAI client with httpx hooks
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=cfg.base_url, api_key=cfg.api_key, http_client=http_client) as client:
results: Dict[str, Any] = {}
if cfg.protocol == "responses":
# For Responses we construct the call per-sample to vary top_p.
for tp in cfg.top_p_grid:
await _guard(interrupt)
texts: List[str] = []
for _ in range(cfg.samples_per_setting):
await _guard(interrupt)
r = await call_with_interrupt(
client.responses.create(
model=cfg.model,
input=[
{"role": "system", "content": "Be helpful and concise."},
{"role": "user", "content": cfg.prompt},
],
temperature=cfg.temperature,
top_p=tp,
),
interrupt.processing_interrupted
)
texts.append(r.output_text or "")
stats = summarize_diversity(texts)
results[f"top_p={tp}"] = {"samples": texts, "stats": stats}
print(f"[responses] top_p={tp} => {stats}")
elif cfg.protocol == "completions":
for tp in cfg.top_p_grid:
await _guard(interrupt)
texts: List[str] = []
first_logprobs: Optional[Any] = None
for k in range(cfg.samples_per_setting):
await _guard(interrupt)
try:
out = await run_completions_batch(
client, cfg.model, cfg.prompt, cfg.temperature, tp, use_logprobs=cfg.use_logprobs and k == 0, interrupt=interrupt
)
texts.append(out["text"])
if first_logprobs is None:
first_logprobs = out["logprobs"]
except Exception as e:
# Fallback if logprobs not supported or param rejected
out = await run_completions_batch(
client, cfg.model, cfg.prompt, cfg.temperature, tp, use_logprobs=False, interrupt=interrupt
)
texts.append(out["text"])
first_logprobs = None
stats = summarize_diversity(texts)
results[f"top_p={tp}"] = {"samples": texts, "stats": stats, "first_logprobs": _maybe_slim_logprobs(first_logprobs)}
print(f"[completions] top_p={tp} => {stats}")
# Persist request payloads and results
with open(os.path.join(cfg.request_log_dir, f"requests_{cfg.protocol}_{sanitize_filename(cfg.model)}.json"), "w", encoding="utf-8") as f:
json.dump(capture.all_payloads, f, ensure_ascii=False, indent=2)
with open(os.path.join(cfg.request_log_dir, f"results_{cfg.protocol}_{sanitize_filename(cfg.model)}.json"), "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\nSaved logs under: {cfg.request_log_dir}")
print("Key check 1: Open requests_*.json to confirm top_p/temperature got sent.")
print("Key check 2: Inspect stats monotonicity across top_p grid. If flat, top_p may be ignored.")
def _maybe_slim_logprobs(lp: Any) -> Any:
"""Reduce logprobs payload size for storage, keeping only first 10 tokens."""
try:
if lp is None:
return None
if hasattr(lp, "content") and lp.content:
# OpenAI SDK returns a structured object; convert to dict and trim
d = lp.model_dump()
if "content" in d and d["content"]:
d["content"] = d["content"][:10]
return d
# If already dict-like
return lp
except Exception:
return None
async def _guard(interrupt: Interruptor):
if await interrupt.processing_interrupted():
raise UserCancelledError("Interrupted by user")
# =========================
# CLI
# =========================
async def amain():
import argparse
parser = argparse.ArgumentParser(description="Async parameter probe for top_p/temperature (Responses or Completions).")
parser.add_argument("--protocol", choices=["responses", "completions"], default="completions")
parser.add_argument("--model", default=os.getenv("OPENAI_MODEL", "gpt-4o-mini"))
parser.add_argument("--base-url", default=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"))
parser.add_argument("--api-key", default=os.getenv("OPENAI_API_KEY", "YOUR_KEY"))
parser.add_argument("--prompt", default="Write a two-sentence review of a fictional cafe. Be creative.")
parser.add_argument("--temperature", type=float, default=0.8)
parser.add_argument("--top-p", default="0.1,0.3,0.7,0.95", help="Comma-separated values to sweep.")
parser.add_argument("--n", type=int, default=8, help="Samples per setting.")
parser.add_argument("--logdir", default="./probe_logs")
parser.add_argument("--no-logprobs", action="store_true", help="Disable logprobs even if supported (completions only).")
args = parser.parse_args()
interrupt = Interruptor()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, interrupt.trigger)
loop.add_signal_handler(signal.SIGTERM, interrupt.trigger)
except NotImplementedError:
pass
cfg = ProbeConfig(
model=args.model,
base_url=args.base_url,
api_key=args.api_key,
protocol=args.protocol,
prompt=args.prompt,
temperature=args.temperature,
top_p_grid=tuple(float(x) for x in args.top_p.split(",")),
samples_per_setting=args.n,
request_log_dir=args.logdir,
use_logprobs=not args.no_logprobs,
)
# Build client and run
try:
await run_probe(cfg, interrupt)
except UserCancelledError:
print("\nInterrupted by user.")
except Exception as e:
print("\nERROR:", repr(e))
if __name__ == "__main__":
asyncio.run(amain())
어떻게 해석하면 되나
-
requests__.json을 열어 실제 요청 바디에 top_p, temperature, max__ 가 들어갔는지 먼저 확인합니다. 이건 “전송은 됐다”를 보장합니다.
-
results_*.json의 stats를 봅니다. top_p가 커질수록 pairwise_jaccard_3gram, avg_ttr, unique_ratio가 보통 증가합니다. 변동이 거의 없다면 모델이 top_p를 무시하는지, temperature가 너무 낮은지, 프롬프트가 지나치게 결정적인지(사실상 한 가지 답) 점검하세요.
-
Completions에서 logprobs가 지원되면 first_logprobs로 토큰 후보와 확률이 들어옵니다. top_p가 낮을 때는 상위 토큰에 확률 질량이 더 집중되는 경향을 간접적으로 볼 수 있습니다. 지원 안 되면 코드가 자동으로 끄고 진행합니다.
실험 팁
-
temperature를 0.7~0.9 정도로 두고 top_p를 스윕하면 차이가 가장 잘 드러납니다. temperature가 0이면 샘플링 자체가 거의 결정적이라 차이가 안 보입니다.
-
프롬프트는 “사실상 정답 한 개”인 지식질문보다, 창의적 생성이나 요약-재서술이 다양성 측정에 유리합니다.
-
max_* 제한을 너무 빡빡하게 주면 모든 샘플이 짧아져 차이가 줄어듭니다.
-
파라미터 오류를 의도적으로 보내 검증해도 좋습니다. 예를 들어 top_p=-1 같은 값은 서버에서 거절돼야 합니다. 거절되지 않으면 해당 엔드포인트가 파라미터를 무시하거나 자체 정규화할 가능성이 있습니다.
이 하네스로 실제 서빙 환경과 설치된 SDK에서 “전송 여부”와 “효과 존재 여부”를 모두 계량적으로 확인할 수 있어요.
종합해서, 내가 말한 모든 것들을 만족하는 파이썬 코드 설계하고 만들어줘. 1. Response/Chat Completions API 선택 + tool JSON format 선택 + LLM Parameter로부터 argument 변환 법 선택 2. Config 파일에 여러 모델 설정에 대해 각각의 모델 + API + JSON 포맷을 미리 정의해둘 거야 (config.toml 파일로 생각 중 - 이 조합 1개를 api_provider라고 부를거고 name(유저가 보는 이름), 모델(호출 시 쓰이는 모델명), base_url, api_key, api 형식, 툴 형식, argument 어댑터 형식을 기입할거야) 그걸 읽어와서 유저가 api_provider name만 보고 고르고 user prompt(+system prompt)와 툴 선택만 하고 실행하면 잘 동작하도록 구성해줘.
# orchestrator_async_configurable.py
# Python 3.10+
# Dependencies: pip install --upgrade openai httpx
#
# -------------------------------------------------------------
# What this file provides
# -------------------------------------------------------------
# 1) A clean, extensible design to run tool-calling LLMs with:
# - API choice: Responses vs Chat Completions
# - Tool JSON shape choice: "flat" (new) vs "nested" (legacy)
# - Argument mapping (neutral → endpoint-specific) strategy choice
# 2) Async-only I/O (AsyncOpenAI + httpx.AsyncClient with hooks)
# 3) User interrupt support via user.processing_interrupted()
# 4) Providers are defined in a config.toml, each called an "api_provider".
# Users pick a provider by name; system/user prompts and tool selection
# are provided at runtime.
#
# -------------------------------------------------------------
# Example config.toml (put this next to the script)
# -------------------------------------------------------------
# [[api_provider]]
# name = "OSS GPT (Responses + Flat + RespAdapter)"
# model = "gpt-4.1-mini"
# base_url = "https://api.openai.com/v1"
# api_key_env = "OPENAI_API_KEY" # or use: api_key = "sk-..."
# api = "responses" # "responses" | "completions"
# tool_json = "flat" # "flat" | "nested"
# arg_adapter = "responses" # "responses" | "completions" | "completions_reasoning"
#
# [[api_provider]]
# name = "Qwen-like (Completions + Nested + CompAdapter)"
# model = "qwen2.5"
# base_url = "https://api.openai.com/v1"
# api_key_env = "QWEN_API_KEY"
# api = "completions"
# tool_json = "nested"
# arg_adapter = "completions"
#
# -------------------------------------------------------------
# CLI usage examples
# -------------------------------------------------------------
# python orchestrator_async_configurable.py \
# --config ./config.toml \
# --provider "OSS GPT (Responses + Flat + RespAdapter)" \
# --system "You are a helpful assistant that uses tools when needed." \
# --user "List files under ./samples, pick a text file, read it, and summarize in 2-3 sentences." \
# --enable-tools list_files_in_folder,read_text_file \
# --length 400 --temperature 0.7 --top-p 0.9
#
# -------------------------------------------------------------
# Notes
# -------------------------------------------------------------
# - This script outputs plain text only (no JSON-mode / structured parsing).
# - Tools are executed locally and results are passed back to the model.
# - Interrupts: SIGINT/SIGTERM will stop ongoing requests cleanly.
# - If tomllib is not available (Python <3.11), you can `pip install tomli`.
#
from __future__ import annotations
import os
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, Tuple
# TOML loader with fallback
try:
import tomllib # Python 3.11+
except Exception:
tomllib = None
try:
import tomli # fallback
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# ============================================================
# Interrupt support
# ============================================================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class UserInterface:
"""
Minimal user interface object that exposes `processing_interrupted()`.
Replace/extend this for GUI/Server integration if needed.
"""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(coro, user: UserInterface, poll_interval: float = 0.2):
"""
Await a coroutine while periodically checking user.processing_interrupted().
If interrupted, cancel the task and raise UserCancelledError.
"""
task = asyncio.create_task(coro)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll_interval)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Processing interrupted by user.")
except Exception:
raise
# ============================================================
# Model & Provider profiles from config
# ============================================================
@dataclass
class ProviderProfile:
"""Single provider (api_provider) loaded from config.toml."""
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
arg_adapter: str # "responses" | "completions" | "completions_reasoning"
def load_providers(config_path: str) -> Dict[str, ProviderProfile]:
"""
Load providers from config.toml (or .json if needed).
Returns a dict mapping provider name -> profile.
"""
if not os.path.exists(config_path):
raise FileNotFoundError(f"Config not found: {config_path}")
# Read bytes
with open(config_path, "rb") as f:
raw = f.read()
# Parse TOML (preferred)
data: Dict[str, Any]
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Install Python 3.11+ or `pip install tomli`.")
else:
# Fallback: JSON
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
raise ValueError("Invalid config: expected [[api_provider]] entries.")
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
arg_adapter = p.get("arg_adapter")
if not name or not model or not base_url or not api or not tool_json or not arg_adapter:
raise ValueError(f"Invalid provider entry: {p}")
# Resolve API key: direct string takes precedence; else from env var.
if not api_key and api_key_env:
api_key = os.getenv(api_key_env)
if not api_key:
raise ValueError(f"API key missing for provider '{name}'. Use api_key or api_key_env.")
profile = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
arg_adapter=arg_adapter,
)
out[name] = profile
return out
# ============================================================
# Tool layer
# ============================================================
@dataclass
class ToolSpec:
"""Single tool spec + local implementation."""
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolRegistry:
"""Holds tools and executes them by name."""
def __init__(self) -> None:
self._tools: Dict[str, ToolSpec] = {}
def register(self, spec: ToolSpec) -> None:
if spec.name in self._tools:
raise ValueError(f"Tool already registered: {spec.name}")
self._tools[spec.name] = spec
def select(self, names: Optional[List[str]] = None) -> List[ToolSpec]:
if not names:
return list(self._tools.values())
selected: List[ToolSpec] = []
for n in names:
if n in self._tools:
selected.append(self._tools[n])
return selected
def call(self, name: str, args: Dict[str, Any]) -> Any:
if name not in self._tools:
return {"error": f"Unknown tool: {name}"}
return self._tools[name].func(args)
# Concrete tools (fast local I/O)
def list_files_in_folder_impl(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def read_text_file_impl(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
def build_default_tools() -> ToolRegistry:
reg = ToolRegistry()
reg.register(ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=list_files_in_folder_impl
))
reg.register(ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to a text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=read_text_file_impl
))
return reg
# ============================================================
# Tool JSON builders (Factory)
# ============================================================
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
"""Flat tool JSON: { "type":"function", "name":..., "description":..., "parameters":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedToolJSONBuilder:
"""Nested tool JSON: { "type":"function", "function":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
def get_tool_builder(kind: str) -> ToolJSONBuilder:
kind = (kind or "").strip().lower()
if kind == "flat":
return FlatToolJSONBuilder()
if kind == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json type: {kind}")
# ============================================================
# Neutral → Endpoint-specific parameter mapping (Adapters)
# ============================================================
@dataclass
class NeutralParams:
"""Neutral params that callers can pass without worrying about endpoint differences."""
length: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
stop: Optional[List[str]] = None
reasoning_effort: Optional[str] = None # "low"|"medium"|"high" (if supported)
class ArgAdapter(Protocol):
def build_for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
def build_for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
class ResponsesArgAdapter:
"""Use max_output_tokens + reasoning in Responses; reasonable defaults elsewhere."""
def build_for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def build_for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
# reasoning may be ignored by non-reasoning chat models
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
class CompletionsArgAdapter:
"""Use max_tokens for completions; map others as common fields."""
def build_for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
# If someone forces this adapter onto Responses: do a best-effort mapping
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def build_for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
return out
class CompletionsReasoningArgAdapter:
"""Use max_completion_tokens + reasoning for reasoning-friendly chat models."""
def build_for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def build_for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_completion_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def get_arg_adapter(kind: str) -> ArgAdapter:
kind = (kind or "").strip().lower()
if kind == "responses":
return ResponsesArgAdapter()
if kind == "completions":
return CompletionsArgAdapter()
if kind == "completions_reasoning":
return CompletionsReasoningArgAdapter()
raise ValueError(f"Unknown arg_adapter: {kind}")
# ============================================================
# Protocol strategies (Async, plain-text only)
# ============================================================
class ProtocolStrategy(Protocol):
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: UserInterface
) -> str:
...
class ResponsesStrategy:
"""
responses.create loop:
- Send input + tools
- Collect text
- If function calls appear: execute locally and append function_call_output
- Repeat until no more tool calls
"""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_tool_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, registry: ToolRegistry) -> None:
self.registry = registry
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: UserInterface
) -> str:
# First call
args = {
"model": model,
"input": messages,
"tools": tools_json,
"tool_choice": "auto",
**payload_args,
}
resp = await call_with_interrupt(
client.responses.create(**args),
user
)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_tool_calls(resp)
if not calls:
break
# Execute tools and append outputs into input messages
for call in calls:
# Check for user interrupt between tool calls
if await user.processing_interrupted():
raise UserCancelledError("Processing interrupted by user.")
result = self.registry.call(call["name"], call["arguments"])
messages.append({
"type": "function_call_output",
"call_id": call["call_id"],
"output": json.dumps(result, ensure_ascii=False),
})
# Next turn
args = {
"model": model,
"input": messages,
"tools": tools_json,
**payload_args,
}
resp = await call_with_interrupt(
client.responses.create(**args),
user
)
return "\n".join([t for t in chunks if t])
class CompletionsStrategy:
"""
chat.completions.create loop:
- Send messages + tools
- If tool_calls appear: execute locally and append tool messages
- Repeat until no more tool_calls
"""
def __init__(self, registry: ToolRegistry) -> None:
self.registry = registry
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: UserInterface
) -> str:
args = {
"model": model,
"messages": messages,
"tools": tools_json,
"tool_choice": "auto",
**payload_args,
}
resp = await call_with_interrupt(
client.chat.completions.create(**args),
user
)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for call in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Processing interrupted by user.")
fn_name = call.function.name
try:
fn_args = json.loads(call.function.arguments or "{}")
except Exception:
fn_args = {}
result = self.registry.call(fn_name, fn_args)
tool_msgs.append({
"role": "tool",
"tool_call_id": call.id,
"name": fn_name,
"content": json.dumps(result, ensure_ascii=False),
})
messages.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
messages.extend(tool_msgs)
args = {
"model": model,
"messages": messages,
**payload_args,
}
resp = await call_with_interrupt(
client.chat.completions.create(**args),
user
)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
def get_protocol_strategy(api: str, registry: ToolRegistry) -> ProtocolStrategy:
api = (api or "").strip().lower()
if api == "responses":
return ResponsesStrategy(registry)
if api == "completions":
return CompletionsStrategy(registry)
raise ValueError(f"Unknown api type: {api}")
# ============================================================
# HTTPX hooks (optional request logging)
# ============================================================
def build_httpx_hooks() -> Dict[str, List[Any]]:
async def on_request(request: httpx.Request):
# Uncomment to debug requests:
# if request.headers.get("content-type","").startswith("application/json"):
# print("[httpx] ->", request.method, request.url)
return
async def on_response(response: httpx.Response):
# Uncomment to debug responses:
# print("[httpx] <-", response.status_code, response.request.url)
return
return {"request": [on_request], "response": [on_response]}
# ============================================================
# Orchestrator (ties everything together)
# ============================================================
@dataclass
class RunOptions:
system_prompt: str
user_prompt: str
enabled_tools: List[str]
neutral_params: NeutralParams
async def run_with_provider(
provider: ProviderProfile,
options: RunOptions,
registry: ToolRegistry,
user: UserInterface
) -> str:
"""
Single-shot run given a ProviderProfile and run options.
"""
# Select tools
specs = registry.select(options.enabled_tools)
# Build tool JSON
tools_builder = get_tool_builder(provider.tool_json)
tools_json = tools_builder.build(specs)
# Build messages
if provider.api == "responses":
# Responses expects "input" messages (role/content pair objects)
messages = [
{"role": "system", "content": options.system_prompt},
{"role": "user", "content": options.user_prompt},
]
else:
# Completions expects chat "messages"
messages = [
{"role": "system", "content": options.system_prompt},
{"role": "user", "content": options.user_prompt},
]
# Build endpoint-specific argument payload
adapter = get_arg_adapter(provider.arg_adapter)
if provider.api == "responses":
payload_args = adapter.build_for_responses(options.neutral_params, messages, tools_json)
else:
payload_args = adapter.build_for_completions(options.neutral_params, messages, tools_json)
# Strategy by API
strategy = get_protocol_strategy(provider.api, registry)
# Async client creation (single session) with hooks
hooks = build_httpx_hooks()
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
# Run and return final plain text
return await strategy.run(
client=client,
model=provider.model,
messages=messages,
tools_json=tools_json,
payload_args=payload_args,
user=user,
)
# ============================================================
# CLI entry point
# ============================================================
async def amain():
import argparse
parser = argparse.ArgumentParser(description="Async LLM Orchestrator with configurable API/Tools/Arg adapters.")
parser.add_argument("--config", required=True, help="Path to config.toml (or .json fallback).")
parser.add_argument("--provider", required=True, help="api_provider name from the config.")
parser.add_argument("--system", default="You are a helpful assistant that uses tools when needed.")
parser.add_argument("--user", required=True, help="User prompt to run.")
parser.add_argument("--enable-tools", default="list_files_in_folder,read_text_file", help="Comma-separated tool names (subset of registered).")
# Neutral params
parser.add_argument("--length", type=int, default=None, help="Neutral length cap (mapped to endpoint-specific).")
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--top-p", type=float, default=None, dest="top_p")
parser.add_argument("--stop", default=None, help="Comma-separated stop tokens.")
parser.add_argument("--reasoning-effort", default=None, help='One of "low","medium","high" (if supported).')
args = parser.parse_args()
# Build the user interrupt controller and wire OS signals
user = UserInterface()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except NotImplementedError:
# Some platforms (e.g., certain Windows envs) may not support this
pass
# Load providers
providers = load_providers(args.config)
if args.provider not in providers:
raise ValueError(f"Provider '{args.provider}' not found in config. Available: {list(providers.keys())}")
provider = providers[args.provider]
# Prepare tools
registry = build_default_tools()
enabled_tools = [t.strip() for t in args.enable_tools.split(",")] if args.enable_tools else []
stop = [s for s in (args.stop.split(",") if args.stop else []) if s]
# Prepare neutral params
neutral = NeutralParams(
length=args.length,
temperature=args.temperature,
top_p=args.top_p,
stop=stop if stop else None,
reasoning_effort=args.reasoning_effort
)
options = RunOptions(
system_prompt=args.system,
user_prompt=args.user,
enabled_tools=enabled_tools,
neutral_params=neutral,
)
try:
output_text = await run_with_provider(provider, options, registry, user)
print("\n=== FINAL OUTPUT ===")
print(output_text or "(empty)")
except UserCancelledError:
print("\n=== INTERRUPTED ===")
except Exception as e:
print("\n=== ERROR ===")
print(repr(e))
if __name__ == "__main__":
asyncio.run(amain())
Protocol 클래스를 상속 받는건 무슨 의미야?
좋은 질문이야. 파이썬의 typing.Protocol을 상속한다는 건 “이 클래스는 인터페이스 규격이다”라고 선언해, 정적 타입체커(mypy, pyright 등)가 ‘덕 타이핑 기반의 구조적 서브타이핑’을 적용하도록 만드는 거야.
핵심만 단계별로 정리할게.
- Protocol이 뭐고, 상속하면 뭐가 달라지나
-
Protocol을 상속한 클래스는 인터페이스(규격)로 취급돼.
-
이 인터페이스를 “구현”하는 쪽은 굳이 상속할 필요가 없어. 같은 시그니처의 메서드·속성을 갖고 있으면 타입체커가 자동으로 호환된다고 본다.
-
즉, 이름으로 맞추는 덕 타이핑을 정적 타입 수준에서 안전하게 쓰게 해준다.
- ABC(Abstract Base Class)와의 차이
-
ABC는 명시적 상속과 추상 메서드 구현이 필요해. 상속관계가 중심이야.
-
Protocol은 구조적(subtype-by-structure)이라 상속 없이도 규격만 맞으면 통과야. 플러그인 구조나 외부 라이브러리 어댑터처럼 “손댈 수 없는 타입들”을 끼워 넣기 좋다.
-
런타임 강제력은 기본적으로 없음. 주로 정적 타입체킹 단계에서 이점이 크다.
- 우리 코드에서 왜 썼나
-
ToolJSONBuilder, ArgAdapter, ProtocolStrategy 같은 “역할”을 인터페이스로 분리했지.
-
새 벤더·새 전략을 추가할 때 굳이 공통 베이스 클래스를 상속하지 않아도, 같은 메서드 시그니처만 맞추면 곧바로 끼워 넣을 수 있어.
-
결과적으로 의존성 역전(DI)과 확장성(새 구현체 추가)에 유리해.
- 최소 예시
아래에서 MyPrinter는 Printable을 상속하지 않았지만, 구조가 같으니 타입체커가 OK라고 본다.
from typing import Protocol
class Printable(Protocol):
def print(self, text: str) -> None: ...
class MyPrinter:
def print(self, text: str) -> None:
print(text)
def run(p: Printable) -> None:
p.print("hello")
run(MyPrinter()) # OK: structural subtyping
- 런타임에서 isinstance 체크가 필요하면
-
기본 Protocol은 런타임 검사용이 아니야. 꼭 필요하면 @runtime_checkable 데코레이터를 붙여서 가벼운 런타임 검사를 허용할 수 있어.
-
그래도 isinstance는 속성 유무만 느슨하게 확인하니, 진짜 강제는 정적 타입체커에 맡기는 게 보통 더 낫다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Printable(Protocol):
def print(self, text: str) -> None: ...
p = MyPrinter()
assert isinstance(p, Printable) # 가능해짐 (정교한 보장은 아님)
- 언제 Protocol이 특히 유용한가
-
플러그인/전략/어댑터 패턴: 서드파티 구현체를 쉽게 꽂을 때
-
테스트 더블 주입: 목/스텁을 상속 없이 바로 넣고 싶을 때
-
멀티 벤더 지원: 서로 다른 SDK 객체라도 같은 표면 API만 맞춰주면 교체 가능
- 주의할 점
-
런타임 강제력이 거의 없으니, 타입 안전성은 mypy/pyright 같은 정적 타입체커를 CI에 넣어 확보하는 게 정석.
-
메서드 시그니처는 정확히 맞춰야 한다. 인자 이름·개수·타입이 빗나가면 타입체커가 잡아줄 것이다.
-
속성(데이터 필드)도 Protocol에 선언할 수 있지만, 구현체가 정확히 제공해야 한다.
정리
Protocol을 상속한다는 건 “이건 인터페이스 명세입니다. 상속 없이도 이 규격만 맞추면 같은 타입으로 인정하세요”라는 신호야. 우리 오케스트레이터에서 확장성을 높이기 위해 Protocol을 쓴 건, 새로운 모델·전략·어댑터를 최소 마찰로 추가하려는 의도였고, 실제로 그게 가장 깔끔한 선택이야.
너는 ComfyUI custom node 전문 제작자이며, 파이썬 코드 전문가야. 난 이 기능을 ComfyUI Custom Node로 만들고 싶어. 참고로 최신 ComfyUI는 async execute 함수를 지원해. 많은 것이 바뀔 수 있으니 잘 생각해서 해보자. OpenAIChatNode - async def run(user_prompt, provider(name), advanced_options(Optional)) 노드 리턴값은 1개이고 openai api를 통해 받은 response를 리턴할거야. OpenAIToolNode - function calling에 대한 정보(실행 함수 포함) 담을거야. 너가 만든 ToolSpec과 비슷할 것 같아. input: 다른 ToolNode의 리스트(None일수도 있음, 그러면 리스트를 만들면 돼) output: 지금 Tool을 인풋에 받은 리스트에 추가하여 리턴 OpenAICustomProviderNode - inputs: base_url, model, api_key, api 형식(completions, response), tool json 형식(flat, nested) - output: 현재 provider의 정보를 담은 객체 OpenAIAdvancedOptionsNode - input은 3개고 모두 optional해. input 1. tools (chat node에서 사용할 tool 정보 tool spec들의 모음 - ToolNode와 연결) input 2. custom provider -> OpenAICustomProviderNode와 연결 input 3. system prompt -> multiline string - output은 받은 정보를 모두 하나의 클래스 객체로 만들어서 chat node에서 이를 참고할 수 있게할 것.
좋아! 아래는 너가 요청한 4개의 커스텀 노드(OpenAIChatNode / OpenAIToolNode / OpenAICustomProviderNode / OpenAIAdvancedOptionsNode)를 한 파일에 담은 완전한 ComfyUI Custom Node 구현이야. 전부 비동기(async) 기반으로 작성했고, Responses/Chat Completions 선택, Tool JSON 포맷 선택(flat/nested), 파라미터 어댑터 선택(responses / completions / completions_reasoning)을 모두 지원해. 또한 config.toml에서 api_provider 목록을 읽어와 사용자는 provider 이름만 선택해도 되고, AdvancedOptions로 커스텀 provider와 도구, 시스템 프롬프트를 덮어쓸 수 있어.
파일명 예시: openai_comfy_nodes.py
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# This module provides four ComfyUI custom nodes:
# 1) OpenAIChatNode
# 2) OpenAIToolNode
# 3) OpenAICustomProviderNode
# 4) OpenAIAdvancedOptionsNode
#
# Design highlights:
# - Async-only execution (AsyncOpenAI + httpx.AsyncClient).
# - Users pick an api_provider by name (loaded from config.toml) or override via AdvancedOptions.
# - Supports Responses vs Chat Completions API, flat vs nested tool JSON, and three argument adapters.
# - ToolNode composes a list of tool specs (built-ins: list_files_in_folder, read_text_file).
# - AdvancedOptions aggregates tools, custom provider, system prompt.
# - ChatNode runs a tool-calling loop and returns the final plain-text response.
#
# Notes:
# - Config path is resolved from env OPENAI_PROVIDER_CONFIG or defaults to ./config.toml.
# - This node avoids JSON-mode/Structured output; it returns plain text only.
# - For cancellation: ComfyUI may cancel the task; we also poll an internal interrupt flag.
# (If your ComfyUI build surfaces a cancellation signal, you can wire it to InterruptUser.trigger_interrupt.)
from __future__ import annotations
import os
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
try:
import tomllib # Python 3.11+
except Exception:
tomllib = None
try:
import tomli
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# ============================================================
# Interrupt helpers
# ============================================================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""
Minimal interrupt controller.
- In ComfyUI, task cancellation raises CancelledError. We also poll this flag for cooperative cancellation.
"""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
# If ComfyUI cancels the task, current task will be cancelled and the await below will raise.
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""
Await an I/O operation while polling for user interruption.
If interrupted, cancel and raise UserCancelledError.
"""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# ============================================================
# Config loading (api_provider catalog)
# ============================================================
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
arg_adapter: str # "responses" | "completions" | "completions_reasoning"
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
Expect config like:
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses"
tool_json = "flat"
arg_adapter = "responses"
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
arg_adapter = p.get("arg_adapter", "responses")
if not name or not model or not base_url or not api or not tool_json:
# Skip invalid entries silently (or raise, if you prefer)
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
# If API key is missing, skip this provider
continue
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
arg_adapter=arg_adapter,
)
return out
# Resolve provider catalog at import-time; can be reloaded in node call if needed
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# ============================================================
# Tool layer
# ============================================================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
"""Flat tool JSON: { "type":"function", "name":..., "description":..., "parameters":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedToolJSONBuilder:
"""Nested tool JSON: { "type":"function", "function":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
def select_tool_builder(kind: str) -> ToolJSONBuilder:
kind = (kind or "").strip().lower()
if kind == "flat":
return FlatToolJSONBuilder()
if kind == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# Built-in tool implementations (safe local I/O)
def _list_files_in_folder(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def _read_text_file(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
BUILTIN_TOOLS: Dict[str, ToolSpec] = {
"list_files_in_folder": ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=_list_files_in_folder
),
"read_text_file": ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to a text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=_read_text_file
),
}
# ============================================================
# Neutral params and argument adapters
# ============================================================
@dataclass
class NeutralParams:
length: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
stop: Optional[List[str]] = None
reasoning_effort: Optional[str] = None # "low"|"medium"|"high"
class ArgAdapter(Protocol):
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
class ResponsesArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
class CompletionsArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
return out
class CompletionsReasoningArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_completion_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": neutral.reasoning_effort}
return out
def get_arg_adapter(kind: str) -> ArgAdapter:
kind = (kind or "").strip().lower()
if kind == "responses":
return ResponsesArgAdapter()
if kind == "completions":
return CompletionsArgAdapter()
if kind == "completions_reasoning":
return CompletionsReasoningArgAdapter()
raise ValueError(f"Unknown arg_adapter: {kind}")
# ============================================================
# Protocol strategies (async, plain-text)
# ============================================================
class ResponsesRunner:
"""Async Responses loop with local tool execution."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
# First call
args = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
args = {"model": model, "input": messages, "tools": tools_json, **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop with local tool execution."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
args = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
args = {"model": model, "messages": messages, **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# ============================================================
# Orchestration
# ============================================================
def select_arg_adapter(kind: str) -> ArgAdapter:
return get_arg_adapter(kind)
def build_payload(provider: ProviderProfile, neutral: NeutralParams, messages: List[Dict[str, Any]], tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
adapter = select_arg_adapter(provider.arg_adapter)
if provider.api == "responses":
payload = adapter.for_responses(neutral, messages, tools_json)
else:
payload = adapter.for_completions(neutral, messages, tools_json)
return {"tools_json": tools_json, "payload_args": payload}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
neutral: NeutralParams,
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, neutral, messages, tools)
tools_json = built["tools_json"]
payload_args = built["payload_args"]
hooks = {"request": [], "response": []} # Add httpx hooks for logging if you want
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
# ============================================================
# Node: OpenAICustomProviderNode
# ============================================================
class OpenAICustomProviderNode:
"""
Build a provider profile object directly from the UI.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
"arg_adapter": (["responses", "completions", "completions_reasoning"],),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, arg_adapter: str):
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
arg_adapter=arg_adapter,
)
return (provider,)
# ============================================================
# Node: OpenAIToolNode
# ============================================================
class OpenAIToolNode:
"""
Compose a list of ToolSpec. If input tools is None, start a new list.
"""
BUILTIN_CHOICES = list(BUILTIN_TOOLS.keys())
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tool": (cls.BUILTIN_CHOICES, {"default": "list_files_in_folder"}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
def append_tool(self, tool: str, tools_in=None):
speclist: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
spec = BUILTIN_TOOLS.get(tool)
if spec:
speclist = speclist + [spec]
return (speclist,)
# ============================================================
# Node: OpenAIAdvancedOptionsNode
# ============================================================
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
class OpenAIAdvancedOptionsNode:
"""
Aggregate optional tools, custom provider, and system prompt into a single options object.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = ""):
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
return (AdvancedOptions(tlist, provider, sys_prompt),)
# ============================================================
# Node: OpenAIChatNode (async)
# ============================================================
class OpenAIChatNode:
"""
Async chat node that:
- Loads provider by name from config.toml (OPENAI_PROVIDER_CONFIG or ./config.toml).
- Optionally overrides with AdvancedOptions.custom_provider.
- Uses AdvancedOptions.tools and system prompt if provided.
- Executes a tool-calling loop and returns final text.
"""
@classmethod
def _provider_names(cls) -> List[str]:
# Reload providers on every call to reflect config changes at runtime
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Resolve provider
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider # override by advanced
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Neutral parameters (you can expose these as additional node inputs if desired)
neutral = NeutralParams(
length=None,
temperature=None,
top_p=None,
stop=None,
reasoning_effort=None
)
# Interrupt support
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
# Not all platforms allow signal handlers (e.g., some Windows envs)
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
neutral=neutral,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# ============================================================
# ComfyUI registration
# ============================================================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAIToolNode": OpenAIToolNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAIToolNode": "OpenAI Tool Builder",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
}
설치와 사용 방법 요약
- 파일 배치
-
위 코드를
ComfyUI/custom_nodes/openai_comfy_nodes.py로 저장. -
OpenAI 파이썬 SDK와 httpx 설치:
pip install --upgrade openai httpx
- config.toml 작성
-
ComfyUI 루트(또는 원하는 경로)에
config.toml생성. 환경 변수OPENAI_PROVIDER_CONFIG로 경로를 바꿀 수 있음. -
예시:
[[api_provider]]
name = "OSS GPT (Responses + Flat + RespAdapter)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
api = "responses"
tool_json = "flat"
arg_adapter = "responses"
[[api_provider]]
name = "Qwen-like (Completions + Nested + CompAdapter)"
model = "qwen2.5"
base_url = "https://api.openai.com/v1"
api_key_env = "QWEN_API_KEY"
api = "completions"
tool_json = "nested"
arg_adapter = "completions"
- 워크플로 구성
-
OpenAIToolNode를 여러 개 연결해서 tools 리스트를 만든다. (예: list_files_in_folder → read_text_file)
-
OpenAICustomProviderNode로 즉석 provider를 만들 수 있고, 또는 config.toml에 있는 provider를 선택할 수도 있다.
-
OpenAIAdvancedOptionsNode에 tools, custom provider, system prompt를 연결해 AdvancedOptions를 만든다.
-
OpenAIChatNode에 user_prompt와 provider_name(드롭다운)을 주고, AdvancedOptions를 선택적으로 붙인다.
-
실행하면 최종 응답 텍스트 1개를 반환한다.
확장 포인트
-
파라미터 노출: OpenAIChatNode INPUT_TYPES에 length/temperature/top_p/stop/reasoning_effort를 옵션으로 추가하고 NeutralParams에 반영하면 UI에서 쉽게 조절 가능.
-
더 많은 툴: OpenAIToolNode에 BUILTIN_TOOLS를 확장하거나, 툴을 동적으로 주입하는 별도 노드를 만들어도 된다.
-
로깅/디버깅: httpx 이벤트 훅으로 요청 페이로드를 캡처해 파일로 남길 수 있다.
-
스트리밍: SDK 스트리밍을 쓰고 싶다면 Responses/Completions 러너에서 스트림 처리 분기를 추가하되, ComfyUI의 프론트엔드 표시 방식에 맞춰야 한다.
필요하면 length/temperature/top_p 같은 매개변수 입력을 바로 Chat 노드에 노출해줄 수도 있어.
아 확장 더 필요해. 너가 말한대로 파라미터 정의 advanced node에 parameters를 multiline으로 받게 해줘. 이 때 json 입력이 기본인데 생각보다 대충 입력해도 잘 알아들을 수 있게 처리해두면 좋겠어. 예를 들어 그냥 reasoning_effort: high max_tokens: 1024 이런 식으로 json 포맷 안 맞춰서 써도 잘 parsing하면 좋겠어. 그리고 생각해보니 CustomProvider 노드 쪽에 arg_adapter도 지정 필요할 것 같네
아래는 요청하신 확장 사항을 반영한 최신 ComfyUI 커스텀 노드 전체 코드예요. 핵심 변경점은 OpenAIAdvancedOptionsNode에 parameters(멀티라인) 입력을 추가하고, 매우 느슨한 파서로 JSON이 아니어도 “key: value” 형식을 유연하게 해석해 NeutralParams로 변환하도록 했다는 점입니다. 또한 OpenAICustomProviderNode에는 arg_adapter 입력이 이미 포함되어 있으며, 이 값을 사용합니다.
파일명 예시: ComfyUI/custom_nodes/openai_comfy_nodes.py
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# Nodes:
# 1) OpenAIChatNode (async)
# 2) OpenAIToolNode
# 3) OpenAICustomProviderNode
# 4) OpenAIAdvancedOptionsNode
#
# Highlights:
# - Async-only execution with AsyncOpenAI + httpx.AsyncClient.
# - Provider selection from config.toml (env OPENAI_PROVIDER_CONFIG or ./config.toml).
# - Optional AdvancedOptions: tools, custom provider override, system prompt, and neutral parameters.
# - Neutral parameters are accepted as multiline "parameters" (loose parser):
# Examples:
# reasoning_effort: high
# max_tokens: 1024
# temperature: 0.7
# top_p: 0.9
# stop: [END, ###]
# - JSON is also accepted; if not JSON, a permissive line parser is used.
# - Supports Responses/Completions, flat/nested tool JSON, and arg adapters
# ("responses" | "completions" | "completions_reasoning").
#
# Note:
# - This node returns plain text only.
# - If your ComfyUI build provides task cancellation hooks, wire them into InterruptUser.trigger_interrupt().
from __future__ import annotations
import os
import re
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
try:
import tomllib # Python 3.11+
except Exception:
tomllib = None
try:
import tomli # Fallback TOML parser
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt helpers
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""
Minimal interrupt controller. If ComfyUI cancels the task, awaiting the
underlying operation will usually raise. We also expose a cooperative flag.
"""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""
Await an I/O operation while polling for user interruption.
If interrupted, cancel and raise UserCancelledError.
"""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# =========================
# Config loading (api_provider catalog)
# =========================
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
arg_adapter: str # "responses" | "completions" | "completions_reasoning"
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
Expected TOML schema:
[[api_provider]]
name = "OSS GPT (Responses + Flat + RespAdapter)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses" # "responses" | "completions"
tool_json = "flat" # "flat" | "nested"
arg_adapter = "responses" # "responses" | "completions" | "completions_reasoning"
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
arg_adapter = p.get("arg_adapter", "responses")
if not name or not model or not base_url or not api or not tool_json:
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
continue
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
arg_adapter=arg_adapter,
)
return out
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# =========================
# Tool layer
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
"""Flat tool JSON: { "type":"function", "name":..., "description":..., "parameters":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedToolJSONBuilder:
"""Nested tool JSON: { "type":"function", "function":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
def select_tool_builder(kind: str) -> ToolJSONBuilder:
kind = (kind or "").strip().lower()
if kind == "flat":
return FlatToolJSONBuilder()
if kind == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# Built-in tools (safe local I/O)
def _list_files_in_folder(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def _read_text_file(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
BUILTIN_TOOLS: Dict[str, ToolSpec] = {
"list_files_in_folder": ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=_list_files_in_folder
),
"read_text_file": ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to a text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=_read_text_file
),
}
# =========================
# Neutral params and arg adapters
# =========================
@dataclass
class NeutralParams:
length: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
stop: Optional[List[str]] = None
reasoning_effort: Optional[str] = None # "low"|"medium"|"high"
class ArgAdapter(Protocol):
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
...
class ResponsesArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
class CompletionsArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
return out
class CompletionsReasoningArgAdapter:
def for_responses(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
def for_completions(self, neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_completion_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
def get_arg_adapter(kind: str) -> ArgAdapter:
kind = (kind or "").strip().lower()
if kind == "responses":
return ResponsesArgAdapter()
if kind == "completions":
return CompletionsArgAdapter()
if kind == "completions_reasoning":
return CompletionsReasoningArgAdapter()
raise ValueError(f"Unknown arg_adapter: {kind}")
# =========================
# Protocol runners (async)
# =========================
class ResponsesRunner:
"""Async Responses loop with local tool execution."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
args = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
args = {"model": model, "input": messages, "tools": tools_json, **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop with local tool execution."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
args = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
args = {"model": model, "messages": messages, **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Orchestration helpers
# =========================
def select_arg_adapter(kind: str) -> ArgAdapter:
return get_arg_adapter(kind)
def build_payload(provider: ProviderProfile, neutral: NeutralParams, messages: List[Dict[str, Any]], tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
adapter = select_arg_adapter(provider.arg_adapter)
if provider.api == "responses":
payload = adapter.for_responses(neutral, messages, tools_json)
else:
payload = adapter.for_completions(neutral, messages, tools_json)
return {"tools_json": tools_json, "payload_args": payload}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
neutral: NeutralParams,
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, neutral, messages, tools)
tools_json = built["tools_json"]
payload_args = built["payload_args"]
hooks = {"request": [], "response": []}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
# =========================
# Loose parameters parsing
# =========================
def _try_json_loads(s: str) -> Optional[Any]:
try:
return json.loads(s)
except Exception:
return None
def _parse_scalar(v: str) -> Any:
t = v.strip()
if t.lower() in ("true", "false"):
return t.lower() == "true"
if t.lower() in ("null", "none"):
return None
# Try numeric
try:
if re.match(r"^-?\d+$", t):
return int(t)
if re.match(r"^-?\d+\.\d+$", t):
return float(t)
except Exception:
pass
# JSON-looking lists or dicts
if (t.startswith("[") and t.endswith("]")) or (t.startswith("{") and t.endswith("}")):
j = _try_json_loads(t)
if j is not None:
return j
# Comma-separated list (fallback)
if "," in t:
parts = [p.strip() for p in t.split(",")]
return [_parse_scalar(p) if isinstance(p, str) else p for p in parts]
return t
def parse_loose_params(text: str) -> Dict[str, Any]:
"""
Permissive parser:
1) Try JSON first.
2) Else parse line-by-line "key: value" or "key = value".
- Ignores empty lines and comment lines starting with # // ;
- Values auto-typed (bool, null, int, float, JSON, comma-list, string).
"""
if not text or not text.strip():
return {}
j = _try_json_loads(text)
if isinstance(j, dict):
return j
out: Dict[str, Any] = {}
lines = text.splitlines()
for raw in lines:
line = raw.strip()
if not line:
continue
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
continue
# Strip trailing in-line comments (only # as heuristic)
line = re.split(r"\s+#", line, maxsplit=1)[0].strip()
# Split on first ":" or "="
m = re.split(r"\s*[:=]\s*", line, maxsplit=1)
if len(m) != 2:
continue
key, val = m[0].strip(), m[1].strip()
if not key:
continue
# Normalize key: lower + replace spaces/hyphens with underscores
norm_key = re.sub(r"[\s\-]+", "_", key.lower())
out[norm_key] = _parse_scalar(val)
return out
def normalize_to_neutral(d: Dict[str, Any]) -> NeutralParams:
"""
Map arbitrary keys into NeutralParams.
Recognized keys:
- length | max_tokens | max_output_tokens | max_completion_tokens
- temperature
- top_p
- stop (list or comma-separated string)
- reasoning_effort | effort | reasoning.effort | reasoning:{effort:...}
"""
if not isinstance(d, dict):
return NeutralParams()
def pick_int(*keys) -> Optional[int]:
for k in keys:
v = d.get(k)
if isinstance(v, (int, float)) and not isinstance(v, bool):
return int(v)
if isinstance(v, str):
try:
return int(float(v))
except Exception:
pass
return None
def pick_float(key: str) -> Optional[float]:
v = d.get(key)
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v)
if isinstance(v, str):
try:
return float(v)
except Exception:
return None
return None
def pick_stop() -> Optional[List[str]]:
v = d.get("stop")
if v is None:
return None
if isinstance(v, list):
return [str(x) for x in v]
if isinstance(v, str):
# Try parse JSON array first
j = _try_json_loads(v)
if isinstance(j, list):
return [str(x) for x in j]
# Else comma-split
return [s.strip() for s in v.split(",") if s.strip()]
# Fallback: single token
return [str(v)]
def pick_effort() -> Optional[str]:
# Prefer explicit keys; fallback to nested dict
for k in ("reasoning_effort", "effort", "reasoning.effort"):
v = d.get(k)
if isinstance(v, str) and v.strip():
return v.strip().lower()
reason = d.get("reasoning")
if isinstance(reason, dict):
v = reason.get("effort")
if isinstance(v, str) and v.strip():
return v.strip().lower()
return None
length = pick_int("length", "max_tokens", "max_output_tokens", "max_completion_tokens")
temperature = pick_float("temperature")
top_p = pick_float("top_p")
stop = pick_stop()
effort = pick_effort()
return NeutralParams(length=length, temperature=temperature, top_p=top_p, stop=stop, reasoning_effort=effort)
# =========================
# Nodes
# =========================
class OpenAICustomProviderNode:
"""
Build a provider profile directly from UI inputs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
"arg_adapter": (["responses", "completions", "completions_reasoning"],),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, arg_adapter: str):
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
arg_adapter=arg_adapter,
)
return (provider,)
class OpenAIToolNode:
"""
Compose a list of ToolSpec. If input list is None, start a new list.
"""
BUILTIN_CHOICES = list(BUILTIN_TOOLS.keys())
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tool": (cls.BUILTIN_CHOICES, {"default": "list_files_in_folder"}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
def append_tool(self, tool: str, tools_in=None):
speclist: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
spec = BUILTIN_TOOLS.get(tool)
if spec:
speclist = speclist + [spec]
return (speclist,)
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
neutral_params: NeutralParams
class OpenAIAdvancedOptionsNode:
"""
Aggregate optional tools, custom provider, system prompt, and loose parameters.
Parameters are parsed from JSON or a permissive "key: value" text.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"parameters": ("STRING", {
"multiline": True,
"default": "reasoning_effort: high\nmax_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n# stop can be a JSON array\n# stop: [END, ###]\n"
}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = "", parameters: str = ""):
# Tools
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
# Provider override
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
# System prompt
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
# Loose parameters -> NeutralParams
params_dict = parse_loose_params(parameters or "")
neutral = normalize_to_neutral(params_dict)
return (AdvancedOptions(tlist, provider, sys_prompt, neutral),)
class OpenAIChatNode:
"""
Async chat node.
- Loads provider by name from config.toml; allows override via AdvancedOptions.custom_provider.
- If AdvancedOptions.parameters provided, they override neutral defaults.
- Runs a tool-calling loop and returns final text.
"""
@classmethod
def _provider_names(cls) -> List[str]:
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Provider selection (config or override)
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Neutral parameters (override by advanced.parameters if present)
neutral = NeutralParams()
if advanced and isinstance(advanced, AdvancedOptions) and advanced.neutral_params:
neutral = advanced.neutral_params
# Interrupt support
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
neutral=neutral,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# =========================
# ComfyUI registration
# =========================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAIToolNode": OpenAIToolNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAIToolNode": "OpenAI Tool Builder",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
}
포인트 요약
-
OpenAIAdvancedOptionsNode에 parameters를 추가했고, JSON이 아니어도 “key: value”를 줄마다 적으면 느슨한 파서가 해석합니다.
-
max_tokens, max_output_tokens, max_completion_tokens, length 중 무엇을 쓰든 NeutralParams.length로 매핑됩니다.
-
stop은 JSON 배열이나 콤마 구분 문자열 모두 지원합니다.
-
reasoning_effort, effort, reasoning.effort, reasoning:{effort:…} 형태 모두 인식합니다.
-
OpenAICustomProviderNode에 arg_adapter가 포함되어 있으며, 이 값으로 argument 매핑 방식을 결정합니다.
-
전체 플로우는 비동기이며, 인터럽트(CTRL+C 등) 시 안전하게 중단됩니다.
흠 아니다 아직 arg_adapter를 넣는건 좀 시기 상조인것 같아. 그냥 전체 코드에서 이에 관련된 모든 코드를 빼줘. 단, 파라미터 정보는 넣을 수 있게 해줘. Config 파일과 Advanced Node에서 설정할거야. 전체 코드 꼼꼼하게 다시 작성해줘.
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# Nodes:
# 1) OpenAIChatNode (async)
# 2) OpenAIToolNode
# 3) OpenAICustomProviderNode
# 4) OpenAIAdvancedOptionsNode
#
# Highlights:
# - Async-only execution (AsyncOpenAI + httpx.AsyncClient).
# - Provider selection from config.toml (env OPENAI_PROVIDER_CONFIG or ./config.toml),
# with optional override via AdvancedOptions.custom_provider.
# - Supports Responses vs Chat Completions API and tool JSON shape (flat/nested).
# - Parameters can be set in both config and AdvancedOptions (multiline, loose parsing).
# - No arg_adapter concept: a single, consistent mapping is applied:
# * Responses: length -> max_output_tokens
# * Completions: length -> max_tokens
# * Both: temperature, top_p, stop, reasoning.effort (best-effort; ignored if unsupported)
#
# Notes:
# - Returns plain text only.
# - If your ComfyUI build exposes task cancellation hooks, wire them into InterruptUser.trigger_interrupt().
from __future__ import annotations
import os
import re
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
# TOML parser
try:
import tomllib # Python 3.11+
except Exception:
tomllib = None
try:
import tomli # Fallback for Python <3.11
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt helpers
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""
Minimal interrupt controller. If ComfyUI cancels the task, awaiting the
underlying operation will usually raise. We also expose a cooperative flag.
"""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""
Await an I/O operation while polling for user interruption.
If interrupted, cancel and raise UserCancelledError.
"""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# =========================
# Config loading (api_provider catalog)
# =========================
@dataclass
class NeutralParams:
length: Optional[int] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
stop: Optional[List[str]] = None
reasoning_effort: Optional[str] = None # "low"|"medium"|"high"
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
default_params: NeutralParams # default neutral parameters from config
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def _try_json_loads(s: str) -> Optional[Any]:
try:
return json.loads(s)
except Exception:
return None
def _parse_scalar(v: str) -> Any:
"""Heuristic scalar parser for loose 'key: value' values."""
t = v.strip()
if t.lower() in ("true", "false"):
return t.lower() == "true"
if t.lower() in ("null", "none"):
return None
# numbers
try:
if re.match(r"^-?\d+$", t):
return int(t)
if re.match(r"^-?\d+\.\d+$", t):
return float(t)
except Exception:
pass
# JSON-looking
if (t.startswith("[") and t.endswith("]")) or (t.startswith("{") and t.endswith("}")):
j = _try_json_loads(t)
if j is not None:
return j
# comma list fallback
if "," in t:
parts = [p.strip() for p in t.split(",")]
return [_parse_scalar(p) if isinstance(p, str) else p for p in parts]
return t
def parse_loose_params(text: str) -> Dict[str, Any]:
"""
Permissive parser:
1) Try JSON first.
2) Else parse line-by-line "key: value" or "key = value".
- Ignores empty lines and lines starting with # // ;
- Auto-typed values (bool, null, int, float, JSON, comma-list, string).
"""
if not text or not text.strip():
return {}
j = _try_json_loads(text)
if isinstance(j, dict):
return j
out: Dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
continue
# strip simple trailing comment after '#'
line = re.split(r"\s+#", line, maxsplit=1)[0].strip()
parts = re.split(r"\s*[:=]\s*", line, maxsplit=1)
if len(parts) != 2:
continue
key, val = parts[0].strip(), parts[1].strip()
if not key:
continue
norm_key = re.sub(r"[\s\-]+", "_", key.lower())
out[norm_key] = _parse_scalar(val)
return out
def normalize_to_neutral(d: Dict[str, Any]) -> NeutralParams:
"""
Map arbitrary keys into NeutralParams.
Recognized keys:
- length | max_tokens | max_output_tokens | max_completion_tokens
- temperature
- top_p
- stop (list or comma-separated string)
- reasoning_effort | effort | reasoning.effort | reasoning:{effort:...}
"""
if not isinstance(d, dict):
return NeutralParams()
def pick_int(*keys) -> Optional[int]:
for k in keys:
v = d.get(k)
if isinstance(v, (int, float)) and not isinstance(v, bool):
return int(v)
if isinstance(v, str):
try:
return int(float(v))
except Exception:
pass
return None
def pick_float(key: str) -> Optional[float]:
v = d.get(key)
if isinstance(v, (int, float)) and not isinstance(v, bool):
return float(v)
if isinstance(v, str):
try:
return float(v)
except Exception:
return None
return None
def pick_stop() -> Optional[List[str]]:
v = d.get("stop")
if v is None:
return None
if isinstance(v, list):
return [str(x) for x in v]
if isinstance(v, str):
j = _try_json_loads(v)
if isinstance(j, list):
return [str(x) for x in j]
return [s.strip() for s in v.split(",") if s.strip()]
return [str(v)]
def pick_effort() -> Optional[str]:
for k in ("reasoning_effort", "effort", "reasoning.effort"):
v = d.get(k)
if isinstance(v, str) and v.strip():
return v.strip().lower()
reason = d.get("reasoning")
if isinstance(reason, dict):
v = reason.get("effort")
if isinstance(v, str) and v.strip():
return v.strip().lower()
return None
length = pick_int("length", "max_tokens", "max_output_tokens", "max_completion_tokens")
temperature = pick_float("temperature")
top_p = pick_float("top_p")
stop = pick_stop()
effort = pick_effort()
return NeutralParams(length=length, temperature=temperature, top_p=top_p, stop=stop, reasoning_effort=effort)
def merge_neutral(base: NeutralParams, override: Optional[NeutralParams]) -> NeutralParams:
"""Override non-None fields from `override` onto `base`."""
if override is None:
return base
return NeutralParams(
length=override.length if override.length is not None else base.length,
temperature=override.temperature if override.temperature is not None else base.temperature,
top_p=override.top_p if override.top_p is not None else base.top_p,
stop=override.stop if override.stop is not None else base.stop,
reasoning_effort=override.reasoning_effort if override.reasoning_effort is not None else base.reasoning_effort,
)
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
Expected TOML schema:
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses" # "responses" | "completions"
tool_json = "flat" # "flat" | "nested"
parameters = """
reasoning_effort: high
max_tokens: 1024
temperature: 0.7
top_p: 0.9
"""
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
raw_params = p.get("parameters", {})
if not name or not model or not base_url or not api or not tool_json:
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
continue
# parameters: dict or multiline string
if isinstance(raw_params, str):
params_dict = parse_loose_params(raw_params)
elif isinstance(raw_params, dict):
params_dict = raw_params
else:
params_dict = {}
default_neutral = normalize_to_neutral(params_dict)
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_params=default_neutral,
)
return out
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# =========================
# Tool layer
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
"""Flat tool JSON: { "type":"function", "name":..., "description":..., "parameters":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"name": s.name,
"description": s.description,
"parameters": s.parameters,
})
return out
class NestedToolJSONBuilder:
"""Nested tool JSON: { "type":"function", "function":{...} }"""
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in specs:
out.append({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters,
}
})
return out
def select_tool_builder(kind: str) -> ToolJSONBuilder:
kind = (kind or "").strip().lower()
if kind == "flat":
return FlatToolJSONBuilder()
if kind == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# Built-in tools (safe local I/O)
def _list_files_in_folder(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def _read_text_file(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
BUILTIN_TOOLS: Dict[str, ToolSpec] = {
"list_files_in_folder": ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=_list_files_in_folder
),
"read_text_file": ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to a text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=_read_text_file
),
}
# =========================
# Protocol runners (async)
# =========================
class ResponsesRunner:
"""Async Responses loop with local tool execution."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
args = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
args = {"model": model, "input": messages, "tools": tools_json, **payload_args}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop with local tool execution."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
payload_args: Dict[str, Any],
user: InterruptUser
) -> str:
args = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto", **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
args = {"model": model, "messages": messages, **payload_args}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Mapping helpers (no arg_adapter)
# =========================
def build_responses_payload(neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Map NeutralParams to Responses API arguments."""
out: Dict[str, Any] = {"input": messages, "tools": tools_json}
if neutral.length is not None:
out["max_output_tokens"] = int(neutral.length)
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)}
return out
def build_completions_payload(neutral: NeutralParams, messages: List[Dict[str, Any]], tools_json: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Map NeutralParams to Chat Completions API arguments."""
out: Dict[str, Any] = {"messages": messages, "tools": tools_json}
if neutral.length is not None:
out["max_tokens"] = int(neutral.length) # unified choice
if neutral.temperature is not None:
out["temperature"] = float(neutral.temperature)
if neutral.top_p is not None:
out["top_p"] = float(neutral.top_p)
if neutral.stop is not None:
out["stop"] = neutral.stop
if neutral.reasoning_effort:
out["reasoning"] = {"effort": str(neutral.reasoning_effort)} # ignored if unsupported
return out
def build_payload(provider: ProviderProfile, neutral: NeutralParams, messages: List[Dict[str, Any]], tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
if provider.api == "responses":
payload = build_responses_payload(neutral, messages, tools_json)
else:
payload = build_completions_payload(neutral, messages, tools_json)
return {"tools_json": tools_json, "payload_args": payload}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
neutral: NeutralParams,
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, neutral, messages, tools)
tools_json = built["tools_json"]
payload_args = built["payload_args"]
hooks = {"request": [], "response": []}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, payload_args, user)
# =========================
# Nodes
# =========================
class OpenAICustomProviderNode:
"""
Build a provider profile directly from UI inputs.
Optional 'parameters' lets you set default neutral parameters for this provider.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
},
"optional": {
"parameters": ("STRING", {
"multiline": True,
"default": "reasoning_effort: high\nmax_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n"
}),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, parameters: str = ""):
params_dict = parse_loose_params(parameters or "")
neutral = normalize_to_neutral(params_dict)
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_params=neutral,
)
return (provider,)
class OpenAIToolNode:
"""
Compose a list of ToolSpec. If input list is None, start a new list.
"""
BUILTIN_CHOICES = list(BUILTIN_TOOLS.keys())
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tool": (cls.BUILTIN_CHOICES, {"default": "list_files_in_folder"}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
def append_tool(self, tool: str, tools_in=None):
speclist: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
spec = BUILTIN_TOOLS.get(tool)
if spec:
speclist = speclist + [spec]
return (speclist,)
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
neutral_params: NeutralParams
class OpenAIAdvancedOptionsNode:
"""
Aggregate optional tools, custom provider, system prompt, and loose parameters.
Parameters are parsed from JSON or a permissive "key: value" text.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"parameters": ("STRING", {
"multiline": True,
"default": "reasoning_effort: high\nmax_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n# stop: [END, ###]\n"
}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = "", parameters: str = ""):
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
params_dict = parse_loose_params(parameters or "")
neutral = normalize_to_neutral(params_dict)
return (AdvancedOptions(tlist, provider, sys_prompt, neutral),)
class OpenAIChatNode:
"""
Async chat node.
- Loads provider by name from config.toml; allows override via AdvancedOptions.custom_provider.
- Parameters: merged from provider.default_params and AdvancedOptions.parameters.
- Runs a tool-calling loop and returns final text.
"""
@classmethod
def _provider_names(cls) -> List[str]:
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Select provider (config or advanced override)
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Merge parameters: provider defaults overridden by advanced
merged_neutral = provider.default_params or NeutralParams()
if advanced and isinstance(advanced, AdvancedOptions) and advanced.neutral_params:
merged_neutral = merge_neutral(merged_neutral, advanced.neutral_params)
# Interrupt support
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
neutral=merged_neutral,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# =========================
# ComfyUI registration
# =========================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAIToolNode": OpenAIToolNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAIToolNode": "OpenAI Tool Builder",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
}
adapter를 빼달라는건 그냥 파라미터로 들어온 걸 그대로 llm 쿼리 파라미터로 넘기면 돼. NeutralParams 이런 컨셉도 그냥 없애고 파라미터는 json 형태로 받아서 그대로 **kwargs로 넘겨버릴거야
요청대로 arg_adapter/NeutralParams 전부 제거하고, config/Advanced에서 받은 parameters를 느슨 파싱해서 그대로 **kwargs로 API 호출에 넘기는 버전이야. Responses/Completions, flat/nested 툴 JSON 모두 지원하고, 파라미터는 사용자가 넣은 값이 기본 인자들을 덮어쓰도록 머지했어.
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# Nodes:
# 1) OpenAIChatNode (async)
# 2) OpenAIToolNode
# 3) OpenAICustomProviderNode
# 4) OpenAIAdvancedOptionsNode
#
# Design:
# - Async-only execution (AsyncOpenAI + httpx.AsyncClient)
# - Provider selection from config.toml (env OPENAI_PROVIDER_CONFIG or ./config.toml)
# - AdvancedOptions may override provider, tools, system prompt, and parameters
# - parameters are parsed permissively (JSON or loose "key: value") and passed
# directly as **kwargs to the underlying API call (no adapter, no remapping)
# - Reserved base args (model/messages/input/tools) are set first, then **parameters
# is merged with precedence to user parameters (they can intentionally override)
from __future__ import annotations
import os
import re
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
# TOML loader
try:
import tomllib # Python 3.11+
except Exception:
tomllib = None
try:
import tomli # fallback for Python <3.11
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt helpers
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""Simple cooperative interrupt controller."""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""
Await an I/O operation while polling for user interruption.
If interrupted, cancel and raise UserCancelledError.
"""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# =========================
# Permissive parameters parsing
# =========================
def _try_json_loads(s: str) -> Optional[Any]:
try:
return json.loads(s)
except Exception:
return None
def _parse_scalar(v: str) -> Any:
t = v.strip()
if t.lower() in ("true", "false"):
return t.lower() == "true"
if t.lower() in ("null", "none"):
return None
try:
if re.match(r"^-?\d+$", t):
return int(t)
if re.match(r"^-?\d+\.\d+$", t):
return float(t)
except Exception:
pass
if (t.startswith("[") and t.endswith("]")) or (t.startswith("{") and t.endswith("}")):
j = _try_json_loads(t)
if j is not None:
return j
if "," in t:
parts = [p.strip() for p in t.split(",")]
return [_parse_scalar(p) if isinstance(p, str) else p for p in parts]
return t
def parse_loose_params(text: str) -> Dict[str, Any]:
"""
1) Try strict JSON.
2) Else parse lines like "key: value" or "key = value" with auto-typing.
Ignores empty lines and comment lines starting with # // ;
"""
if not text or not text.strip():
return {}
j = _try_json_loads(text)
if isinstance(j, dict):
return j
out: Dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
continue
line = re.split(r"\s+#", line, maxsplit=1)[0].strip() # strip trailing comment after '#'
parts = re.split(r"\s*[:=]\s*", line, maxsplit=1)
if len(parts) != 2:
continue
key, val = parts[0].strip(), parts[1].strip()
if not key:
continue
norm_key = re.sub(r"[\s\-]+", "_", key.lower())
out[norm_key] = _parse_scalar(val)
return out
# =========================
# Provider config
# =========================
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
default_parameters: Dict[str, Any] # passed as **kwargs
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
Expected TOML:
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses" # "responses" | "completions"
tool_json = "flat" # "flat" | "nested"
parameters = """
# any fields accepted by the chosen API will be forwarded verbatim
max_output_tokens: 1024
temperature: 0.7
top_p: 0.9
tool_choice: auto
"""
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
raw_params = p.get("parameters", {})
if not name or not model or not base_url or not api or not tool_json:
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
continue
if isinstance(raw_params, str):
params_dict = parse_loose_params(raw_params)
elif isinstance(raw_params, dict):
params_dict = raw_params
else:
params_dict = {}
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return out
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# =========================
# Tools
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "name": s.name, "description": s.description, "parameters": s.parameters} for s in specs]
class NestedToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "function": {"name": s.name, "description": s.description, "parameters": s.parameters}} for s in specs]
def select_tool_builder(kind: str) -> ToolJSONBuilder:
k = (kind or "").strip().lower()
if k == "flat":
return FlatToolJSONBuilder()
if k == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# Built-in tool implementations
def _list_files_in_folder(args: Dict[str, Any]) -> Dict[str, Any]:
folder = args.get("folder_path", "")
try:
items = sorted([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
except Exception:
items = []
return {"result": items}
def _read_text_file(args: Dict[str, Any]) -> Dict[str, Any]:
path = args.get("file_path", "")
if not os.path.exists(path):
return {"result": ""}
try:
with open(path, "r", encoding="utf-8") as f:
return {"result": f.read()}
except Exception:
return {"result": ""}
BUILTIN_TOOLS: Dict[str, ToolSpec] = {
"list_files_in_folder": ToolSpec(
name="list_files_in_folder",
description="List file names (not directories) inside a given folder path.",
parameters={
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Absolute or relative folder path."}
},
"required": ["folder_path"],
"additionalProperties": False
},
func=_list_files_in_folder
),
"read_text_file": ToolSpec(
name="read_text_file",
description="Read a UTF-8 text file and return its content.",
parameters={
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute or relative path to a text file."}
},
"required": ["file_path"],
"additionalProperties": False
},
func=_read_text_file
),
}
# =========================
# Protocol runners (async), passing **kwargs verbatim
# =========================
class ResponsesRunner:
"""Async Responses loop; tool calls executed locally."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
# Base args; user params can override any of them (including input/tools/tool_choice)
base = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
base = {"model": model, "input": messages, "tools": tools_json}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop; tool calls executed locally."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
base = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
base = {"model": model, "messages": messages}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Orchestrator
# =========================
def build_payload(provider: ProviderProfile, messages: List[Dict[str, Any]], tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
return {"tools_json": tools_json, "params": dict(provider.default_parameters or {})}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
params_override: Dict[str, Any],
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, messages, tools)
tools_json = built["tools_json"]
params = built["params"]
# Advanced parameters override provider defaults (right wins)
if params_override:
params = {**params, **params_override}
hooks = {"request": [], "response": []}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
# =========================
# Nodes
# =========================
class OpenAICustomProviderNode:
"""
Build a provider profile directly from UI inputs.
Optional 'parameters' lets you set default kwargs to forward verbatim.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
},
"optional": {
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\nmax_output_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n"
}),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, parameters: str = ""):
params_dict = parse_loose_params(parameters or "")
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return (provider,)
class OpenAIToolNode:
"""
Compose a list of ToolSpec. If input list is None, start a new list.
"""
BUILTIN_CHOICES = list(BUILTIN_TOOLS.keys())
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tool": (cls.BUILTIN_CHOICES, {"default": "list_files_in_folder"}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
def append_tool(self, tool: str, tools_in=None):
speclist: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
spec = BUILTIN_TOOLS.get(tool)
if spec:
speclist = speclist + [spec]
return (speclist,)
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
parameters: Dict[str, Any] # forwarded as **kwargs
class OpenAIAdvancedOptionsNode:
"""
Aggregate optional tools, custom provider, system prompt, and parameters.
'parameters' is parsed with parse_loose_params and forwarded verbatim as **kwargs.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\n# e.g., max_tokens / max_output_tokens / top_p / temperature / stop\n"
}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = "", parameters: str = ""):
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
params_dict = parse_loose_params(parameters or "")
return (AdvancedOptions(tlist, provider, sys_prompt, params_dict),)
class OpenAIChatNode:
"""
Async chat node:
- Provider chosen by name from config.toml (auto-refreshed), overrideable via AdvancedOptions.custom_provider
- parameters: provider.default_parameters merged with AdvancedOptions.parameters (right wins)
- Executes tool-calling loop and returns final text
"""
@classmethod
def _provider_names(cls) -> List[str]:
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Resolve provider
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Merge parameters: provider defaults overridden by advanced
params_override: Dict[str, Any] = {}
if advanced and isinstance(advanced, AdvancedOptions) and advanced.parameters:
params_override = advanced.parameters
# Interrupt support
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
params_override=params_override,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# =========================
# ComfyUI registration
# =========================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAIToolNode": OpenAIToolNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAIToolNode": "OpenAI Tool Builder",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
}
빠른 사용 가이드
-
ComfyUI/custom_nodes/ 아래에 파일 저장 후 재시작.
-
config.toml 예시
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
api = "responses"
tool_json = "flat"
parameters = """
tool_choice: auto
max_output_tokens: 1024
temperature: 0.7
top_p: 0.9
"""
- 워크플로우에서 AdvancedOptions의 parameters에
reasoning: {effort: high}
stop: [END, ###]
같이 넣으면 그대로 **kwargs로 API에 전달돼. 필요한 필드는 모델/엔드포인트 문서 기준으로 자유롭게 넣으면 된다.
BuiltIn Tools로 다 선언하지 말고 각각 Tool 노드에서 툴을 만들게 하고 싶어. 예를 들어 추가적인 입력(예를 들어 경로라든지)를 노드에서 받아서 dynamic하게 만드는게 더 좋을 것 같아. call back을 통해 ui 업데이트 같은거도 넣고싶고.
좋아. “빌트인 툴”을 없애고, 각 Tool 노드에서 동적으로 툴을 만들도록 전체 구조를 바꿨다. 아래 코드는 다음을 포함한다.
-
OpenAIChatNode (async): provider + tools + parameters로 호출, 툴 콜 처리 루프 포함
-
OpenAIToolNode: 선택한 tool_type과 추가 입력(예: base_dir, encoding 등)으로 동적 ToolSpec 생성하여 리스트에 추가
-
OpenAICustomProviderNode: provider를 UI에서 직접 생성 (parameters를 그대로 **kwargs로 전달)
-
OpenAIAdvancedOptionsNode: tools, custom provider, system prompt, parameters를 하나로 묶음
-
OpenAICallbackNode: 간단한 콜백 핸들; 툴 실행 시 emit(event, payload) 호출로 UI/로그 업데이트 훅 제공
파라미터는 config/Advanced에서 받은 문자열을 느슨 파싱(엄격 JSON 또는 key: value 라인) 후 그대로 **kwargs로 API에 전달한다. arg_adapter/NeutralParams는 완전히 제거했다. Tool JSON 형식(flat/nested)과 Responses/Completions 모두 지원한다.
파일: ComfyUI/custom_nodes/openai_comfy_nodes.py
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# Nodes:
# 1) OpenAIChatNode (async)
# 2) OpenAIToolNode (dynamic tool builder; appends to list)
# 3) OpenAICustomProviderNode
# 4) OpenAIAdvancedOptionsNode
# 5) OpenAICallbackNode (optional callback sink)
#
# Design:
# - Async-only (AsyncOpenAI + httpx.AsyncClient).
# - Provider chosen by name from config.toml (OPENAI_PROVIDER_CONFIG or ./config.toml),
# or overridden via AdvancedOptions.
# - Parameters are parsed permissively and forwarded verbatim as **kwargs.
# - Tools are created per Tool node (no built-ins registry). Each node builds a ToolSpec
# with a closure that can capture node inputs (e.g., base_dir, encoding) and an optional callback.
# - Tool JSON: flat or nested.
# - Tool calls are executed locally; responses/completions loop until no tool calls.
from __future__ import annotations
import os
import re
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
# TOML loader
try:
import tomllib # Py3.11+
except Exception:
tomllib = None
try:
import tomli # Fallback
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt helpers
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""Cooperative interrupt controller."""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""Await an I/O while polling interruption; cancel if interrupted."""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# =========================
# Permissive parameters parsing
# =========================
def _try_json_loads(s: str) -> Optional[Any]:
try:
return json.loads(s)
except Exception:
return None
def _parse_scalar(v: str) -> Any:
t = v.strip()
if t.lower() in ("true", "false"):
return t.lower() == "true"
if t.lower() in ("null", "none"):
return None
try:
if re.match(r"^-?\d+$", t):
return int(t)
if re.match(r"^-?\d+\.\d+$", t):
return float(t)
except Exception:
pass
if (t.startswith("[") and t.endswith("]")) or (t.startswith("{") and t.endswith("}")):
j = _try_json_loads(t)
if j is not None:
return j
if "," in t:
parts = [p.strip() for p in t.split(",")]
return [_parse_scalar(p) if isinstance(p, str) else p for p in parts]
return t
def parse_loose_params(text: str) -> Dict[str, Any]:
"""
1) Try strict JSON.
2) Else parse lines like "key: value" or "key = value" with auto-typing.
Ignores empty lines and comment lines starting with # // ;
"""
if not text or not text.strip():
return {}
j = _try_json_loads(text)
if isinstance(j, dict):
return j
out: Dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
continue
line = re.split(r"\s+#", line, maxsplit=1)[0].strip() # strip trailing comment after '#'
parts = re.split(r"\s*[:=]\s*", line, maxsplit=1)
if len(parts) != 2:
continue
key, val = parts[0].strip(), parts[1].strip()
if not key:
continue
norm_key = re.sub(r"[\s\-]+", "_", key.lower())
out[norm_key] = _parse_scalar(val)
return out
# =========================
# Provider config
# =========================
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
default_parameters: Dict[str, Any] # forwarded as **kwargs
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses" # "responses" | "completions"
tool_json = "flat" # "flat" | "nested"
parameters = '''
tool_choice: auto
max_output_tokens: 1024
temperature: 0.7
top_p: 0.9
'''
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
raw_params = p.get("parameters", {})
if not name or not model or not base_url or not api or not tool_json:
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
continue
if isinstance(raw_params, str):
params_dict = parse_loose_params(raw_params)
elif isinstance(raw_params, dict):
params_dict = raw_params
else:
params_dict = {}
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return out
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# =========================
# Tool layer (dynamic, per-node)
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "name": s.name, "description": s.description, "parameters": s.parameters} for s in specs]
class NestedToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "function": {"name": s.name, "description": s.description, "parameters": s.parameters}} for s in specs]
def select_tool_builder(kind: str) -> ToolJSONBuilder:
k = (kind or "").strip().lower()
if k == "flat":
return FlatToolJSONBuilder()
if k == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# =========================
# Optional callback sink
# =========================
@dataclass
class CallbackSink:
"""Lightweight event sink. You can attach a UI listener to consume .events."""
label: str = "OpenAI"
events: List[Dict[str, Any]] = None
def __post_init__(self):
if self.events is None:
self.events = []
def emit(self, event: str, payload: Dict[str, Any]):
rec = {"event": event, "payload": payload}
self.events.append(rec)
# Basic side-effect for visibility; replace with your UI integration if needed
print(f"[{self.label}] {event}: {payload}")
# =========================
# Protocol runners (async), **kwargs passthrough
# =========================
class ResponsesRunner:
"""Async Responses loop; tool calls executed locally."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
base = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
base = {"model": model, "input": messages, "tools": tools_json}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop; tool calls executed locally."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
base = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
base = {"model": model, "messages": messages}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Orchestrator
# =========================
def build_payload(provider: ProviderProfile, tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
return {"tools_json": tools_json, "params": dict(provider.default_parameters or {})}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
params_override: Dict[str, Any],
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, tools)
tools_json = built["tools_json"]
params = built["params"]
if params_override:
params = {**params, **params_override} # right wins
hooks = {"request": [], "response": []}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
# =========================
# Nodes
# =========================
# -- Provider
class OpenAICustomProviderNode:
"""Create a provider profile directly from UI inputs; parameters forwarded verbatim as **kwargs."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
},
"optional": {
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\nmax_output_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n"
}),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, parameters: str = ""):
params_dict = parse_loose_params(parameters or "")
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return (provider,)
# -- Callback sink
class OpenAICallbackNode:
"""Create a simple callback sink. Tools can emit(event, payload) to this sink."""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"label": ("STRING", {"default": "OpenAI"}),
}
}
RETURN_TYPES = ("OPENAI_CALLBACK",)
RETURN_NAMES = ("callback",)
FUNCTION = "build"
CATEGORY = "OpenAI/Utils"
def build(self, label: str = "OpenAI"):
return (CallbackSink(label=label),)
# -- Dynamic Tool Builder
class OpenAIToolNode:
"""
Build a dynamic tool and append to an incoming tool list.
Supported tool_type:
- list_files_in_folder
- read_text_file
Extra inputs are captured in the tool closure (e.g., base_dir, encoding, callback).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tool_type": (["list_files_in_folder", "read_text_file"],),
"name": ("STRING", {"default": "list_files_in_folder"}),
"description": ("STRING", {"default": "List files or read text file."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",), # existing list to append to
"base_dir": ("STRING", {"default": ""}),
"return_full_paths": ("BOOLEAN", {"default": False}),
"encoding": ("STRING", {"default": "utf-8"}), # for read_text_file
"callback": ("OPENAI_CALLBACK",), # optional
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
# ---- helpers
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
"""Resolve path under base_dir to avoid traversal; return None if outside."""
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def _make_list_files_tool(self, name: str, description: str, base_dir: str, return_full_paths: bool, cb: Optional[CallbackSink]) -> ToolSpec:
schema = {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Folder path (relative to base_dir if provided)."},
"pattern": {"type": "string", "description": "Optional glob pattern, e.g., *.txt"},
"absolute": {"type": "boolean", "description": "Return absolute paths (overrides node default)."}
},
"required": ["folder_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
folder = str(args.get("folder_path", "") or "")
pattern = str(args.get("pattern", "") or "")
absolute = bool(args.get("absolute", return_full_paths))
folder_resolved = self._safe_join(base_dir, folder)
if not folder_resolved or not os.path.isdir(folder_resolved):
if cb: cb.emit("tool_error", {"tool": name, "reason": "invalid_folder", "folder": folder})
return {"result": []}
import glob
glob_target = os.path.join(folder_resolved, pattern) if pattern else os.path.join(folder_resolved, "*")
files = [p for p in glob.glob(glob_target) if os.path.isfile(p)]
out = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
if cb: cb.emit("tool_done", {"tool": name, "count": len(out)})
return {"result": sorted(out)}
return ToolSpec(name=name, description=description, parameters=schema, func=_impl)
def _make_read_text_tool(self, name: str, description: str, base_dir: str, encoding: str, cb: Optional[CallbackSink]) -> ToolSpec:
schema = {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "File path (relative to base_dir if provided)."},
"encoding": {"type": "string", "description": "Optional override for text encoding."}
},
"required": ["file_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
file_path = str(args.get("file_path", "") or "")
enc = str(args.get("encoding", encoding) or encoding)
file_resolved = self._safe_join(base_dir, file_path)
if not file_resolved or not os.path.isfile(file_resolved):
if cb: cb.emit("tool_error", {"tool": name, "reason": "invalid_file", "file": file_path})
return {"result": ""}
try:
with open(file_resolved, "r", encoding=enc) as f:
text = f.read()
if cb: cb.emit("tool_done", {"tool": name, "size": len(text)})
return {"result": text}
except Exception as e:
if cb: cb.emit("tool_error", {"tool": name, "reason": "read_failed", "error": repr(e)})
return {"result": ""}
return ToolSpec(name=name, description=description, parameters=schema, func=_impl)
# ---- core
def append_tool(
self,
tool_type: str,
name: str,
description: str,
tools_in=None,
base_dir: str = "",
return_full_paths: bool = False,
encoding: str = "utf-8",
callback: Optional[CallbackSink] = None
):
speclist: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
if tool_type == "list_files_in_folder":
spec = self._make_list_files_tool(name or "list_files_in_folder", description, base_dir, return_full_paths, callback)
elif tool_type == "read_text_file":
spec = self._make_read_text_tool(name or "read_text_file", description, base_dir, encoding, callback)
else:
# Unknown tool type -> no-op append
return (speclist,)
return (speclist + [spec],)
# -- Advanced options aggregator
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
parameters: Dict[str, Any] # forwarded as **kwargs
class OpenAIAdvancedOptionsNode:
"""Aggregate tools, custom provider, system prompt, and parameters (verbatim **kwargs)."""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\n# e.g., max_tokens / max_output_tokens / top_p / temperature / stop\n"
}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = "", parameters: str = ""):
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
params_dict = parse_loose_params(parameters or "")
return (AdvancedOptions(tlist, provider, sys_prompt, params_dict),)
# -- Chat
class OpenAIChatNode:
"""
Async chat node:
- Provider chosen by name from config.toml (auto-refreshed), overrideable via AdvancedOptions.custom_provider
- Parameters: provider.default_parameters merged with AdvancedOptions.parameters (right wins)
- Executes tool-calling loop and returns final text
"""
@classmethod
def _provider_names(cls) -> List[str]:
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Resolve provider
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Merge parameters: provider defaults overridden by advanced
params_override: Dict[str, Any] = {}
if advanced and isinstance(advanced, AdvancedOptions) and advanced.parameters:
params_override = advanced.parameters
# Interrupt
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
params_override=params_override,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# =========================
# ComfyUI registration
# =========================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAIToolNode": OpenAIToolNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
"OpenAICallbackNode": OpenAICallbackNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAIToolNode": "OpenAI Tool Builder (Dynamic)",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
"OpenAICallbackNode": "OpenAI Callback Sink",
}
어떻게 쓰면 되나
- Tool 만들기
OpenAIToolNode를 여러 개 배치해서 각기 다른 툴을 만든다. 예를 들어:
-
tool_type = list_files_in_folder, base_dir = ./data, return_full_paths = True, callback 연결
-
tool_type = read_text_file, base_dir = ./data, encoding = utf-8, 같은 callback 연결
각 Tool 노드는 입력 tools_in으로 이전 툴 리스트를 받아 이어 붙인다.
-
Callback 연결
OpenAICallbackNode를 하나 만들고, Tool 노드의 callback 입력에 연결하면 툴 실행 때마다 emit이 호출되어 콘솔/이벤트 리스트가 갱신된다. 필요하면 이 Sink를 받아 다른 노드에서 이벤트를 확인하는 확장도 가능하다. -
AdvancedOptions
-
tools: 위에서 만든 툴 리스트 연결
-
custom_provider: 필요 시 OpenAICustomProviderNode의 출력을 연결
-
system_prompt: 멀티라인
-
parameters: JSON 또는 느슨한 key: value 형식으로 입력. 여기 적은 값은 그대로 **kwargs로 API 호출에 전달된다. 예:
tool_choice: auto
max_output_tokens: 1024
temperature: 0.7
top_p: 0.9
stop: ["END"]
- Chat 실행
OpenAIChatNode에서 provider_name을 config.toml의 이름으로 선택하거나, AdvancedOptions에서 custom_provider를 넘겨 덮어쓴다. user_prompt를 입력하고 실행하면 된다.
확장 팁
-
Tool 종류 추가: OpenAIToolNode.make * 함수를 추가하고 INPUT_TYPES에 필요한 옵션을 늘리면 된다. 파라미터 스키마는 tool JSON에 그대로 들어가고, 실행 로직은 클로저로 캡처된다.
-
UI 업데이트: 콜백 Sink의 emit에서 print 대신 소켓/파일/전역 상태 갱신 등 원하는 방식으로 확장 가능하다.
-
보안: _safe_join으로 base_dir 밖 파일 접근을 차단한다. 더 강한 샌드박스가 필요하면 여기서 강화하면 된다.
지금 코드는 하나의 노드에서 모두 다 하려고 하는 것 같아(툴). 나는 파일 읽는 툴 노드, 파일 쓰는 툴 노드, 파일 리스트 보는 툴 노드 다 따로 하는 걸 생각했는데, 어떤게 더 나을것 같아? 그런데 툴 노드는 하나라도 위의 3개 기능을 구현하려면 3개 툴을 넣긴 해야하는건가? 하나의 툴에서 3가지를 다 하게 할 수도 있나?
한마디로 정리하면, 둘 다 가능하고 장단이 뚜렷해. 기본값으로는 “기능별(읽기/쓰기/리스트)로 나눈 원자적 툴”을 추천하고, 필요하면 “하나의 툴로 액션을 분기”하는 번들형을 보조로 두는 하이브리드가 가장 관리가 쉬워.
왜 원자적 툴(읽기=1, 쓰기=1, 리스트=1)을 추천하나
-
모델 라우팅 정확도
툴 명과 파라미터가 명확할수록 LLM이 올바른 툴을 고르는 확률이 높아져. 하나의 툴에 action 필드를 두면 모델이 잘못된 파라미터 조합을 넣거나 action을 틀리게 고를 가능성이 증가. -
스키마 단순성/검증 용이성
각 툴이 자기 파라미터만 엄격히 받으니 검증이 간단하고, 에러 메시지도 명확해. -
권한/보안 제어
예를 들어 프로덕션에서는 쓰기 툴만 막고 싶을 수 있어. 툴이 분리돼 있으면 선택적으로 비활성화하기 쉽다. -
병렬성/성능
리스트→여러 파일 읽기 같은 플로우에서 모델이 병렬 툴 콜을 만들 수도 있어. 액션 하나짜리 거대 툴보다 병렬화가 자연스럽다.
언제 단일 멀티액션 툴이 나은가
-
툴 갯수 제한이 빡빡한 모델/플랫폼을 쓰는 경우
한 개 툴로 액션을 분기하면 툴 수를 줄일 수 있어. -
액션들이 강하게 결합되어 있거나 같은 전처리를 공유하는 경우
예를 들어 동일한 권한·캐시·세션 핸들러를 반드시 공유해야 한다면 하나로 묶어 이점이 있다. -
그래프 단순화가 중요한 경우
UI 배선이 복잡해지는 걸 피하고 싶다면 번들형이 깔끔하다.
결론 제안
기본은 기능별로 3개 툴 노드(FileListToolNode, FileReadToolNode, FileWriteToolNode) + ToolsMergeNode로 합치는 구조를 쓰자. 보조로 FileManagerToolNode(단일 멀티액션)를 제공해, 툴 수 제한이 있는 프로바이더나 간단한 워크플로우에서 선택적으로 사용하도록 하자.
아래는 네가 이미 가진 코드베이스에 바로 추가/교체해서 쓸 수 있는 최소 구현 예시들이야. 기존 OpenAIChatNode, OpenAIAdvancedOptionsNode 등은 그대로 두고, 툴 관련 부분만 이 클래스들을 추가해서 쓰면 된다.
ToolsMergeNode: 여러 툴 리스트를 합치는 유틸 노드
class ToolsMergeNode:
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools_a": ("OPENAI_TOOLS",),
"tools_b": ("OPENAI_TOOLS",),
"tools_c": ("OPENAI_TOOLS",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "merge"
CATEGORY = "OpenAI/Tools"
def merge(self, tools_a=None, tools_b=None, tools_c=None):
out = []
for t in (tools_a, tools_b, tools_c):
if isinstance(t, list):
out.extend(t)
return (out,)
원자적 툴 1: 파일 리스트
class FileListToolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "list_files_in_folder"}),
"description": ("STRING", {"default": "List files in a folder."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"return_full_paths": ("BOOLEAN", {"default": False}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", return_full_paths=False, callback=None):
schema = {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Folder path (relative to base_dir if set)."},
"pattern": {"type": "string", "description": "Optional glob, e.g. *.txt"},
"absolute": {"type": "boolean", "description": "Force absolute paths override."}
},
"required": ["folder_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
folder = str(args.get("folder_path",""))
pattern = str(args.get("pattern",""))
absolute = bool(args.get("absolute", return_full_paths))
resolved = self._safe_join(base_dir, folder)
if not resolved or not os.path.isdir(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_folder", "folder": folder})
return {"result": []}
import glob
g = os.path.join(resolved, pattern) if pattern else os.path.join(resolved, "*")
files = [p for p in glob.glob(g) if os.path.isfile(p)]
out = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
if callback: callback.emit("tool_done", {"tool": name, "count": len(out)})
return {"result": sorted(out)}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
원자적 툴 2: 파일 읽기
class FileReadToolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "read_text_file"}),
"description": ("STRING", {"default": "Read a UTF-8 text file."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"encoding": ("STRING", {"default": "utf-8"}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", encoding="utf-8", callback=None):
schema = {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "File path (relative to base_dir if set)."},
"encoding": {"type": "string", "description": "Override encoding."}
},
"required": ["file_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
path = str(args.get("file_path",""))
enc = str(args.get("encoding", encoding))
resolved = self._safe_join(base_dir, path)
if not resolved or not os.path.isfile(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_file", "file": path})
return {"result": ""}
try:
with open(resolved, "r", encoding=enc) as f:
text = f.read()
if callback: callback.emit("tool_done", {"tool": name, "size": len(text)})
return {"result": text}
except Exception as e:
if callback: callback.emit("tool_error", {"tool": name, "reason": "read_failed", "error": repr(e)})
return {"result": ""}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
원자적 툴 3: 파일 쓰기
class FileWriteToolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "write_text_file"}),
"description": ("STRING", {"default": "Write text to a file (creates dirs)."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"encoding": ("STRING", {"default": "utf-8"}),
"append": ("BOOLEAN", {"default": False}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", encoding="utf-8", append=False, callback=None):
schema = {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "File path (relative to base_dir if set)."},
"content": {"type": "string", "description": "Text content to write."},
"encoding": {"type": "string", "description": "Override encoding."},
"append": {"type": "boolean", "description": "Append to file instead of overwrite."}
},
"required": ["file_path", "content"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
path = str(args.get("file_path",""))
content = str(args.get("content",""))
enc = str(args.get("encoding", encoding))
do_append = bool(args.get("append", append))
resolved = self._safe_join(base_dir, path)
if not resolved:
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_path", "file": path})
return {"result": False}
try:
os.makedirs(os.path.dirname(resolved), exist_ok=True)
mode = "a" if do_append else "w"
with open(resolved, mode, encoding=enc) as f:
f.write(content)
if callback: callback.emit("tool_done", {"tool": name, "bytes": len(content)})
return {"result": True}
except Exception as e:
if callback: callback.emit("tool_error", {"tool": name, "reason": "write_failed", "error": repr(e)})
return {"result": False}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
번들형 툴: 하나의 툴로 list/read/write를 모두 처리
class FileManagerToolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "file_manager"}),
"description": ("STRING", {"default": "List, read, or write files via action."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"default_encoding": ("STRING", {"default": "utf-8"}),
"return_full_paths": ("BOOLEAN", {"default": False}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", default_encoding="utf-8", return_full_paths=False, callback=None):
# Note: oneOf를 모델들이 충실히 따르지 않는 경우가 많아, action + 선택적 필드로 설계하고 런타임에서 검증
schema = {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "read", "write"]},
"folder_path": {"type": "string"},
"pattern": {"type": "string"},
"absolute": {"type": "boolean"},
"file_path": {"type": "string"},
"encoding": {"type": "string"},
"append": {"type": "boolean"},
"content": {"type": "string"}
},
"required": ["action"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
action = str(args.get("action",""))
try:
if action == "list":
folder = str(args.get("folder_path",""))
pattern = str(args.get("pattern",""))
absolute = bool(args.get("absolute", return_full_paths))
resolved = self._safe_join(base_dir, folder)
if not resolved or not os.path.isdir(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_folder", "folder": folder})
return {"result": []}
import glob
g = os.path.join(resolved, pattern) if pattern else os.path.join(resolved, "*")
files = [p for p in glob.glob(g) if os.path.isfile(p)]
out = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
if callback: callback.emit("tool_done", {"tool": name, "action": "list", "count": len(out)})
return {"result": sorted(out)}
if action == "read":
file_path = str(args.get("file_path",""))
enc = str(args.get("encoding", default_encoding))
resolved = self._safe_join(base_dir, file_path)
if not resolved or not os.path.isfile(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_file", "file": file_path})
return {"result": ""}
with open(resolved, "r", encoding=enc) as f:
text = f.read()
if callback: callback.emit("tool_done", {"tool": name, "action": "read", "size": len(text)})
return {"result": text}
if action == "write":
file_path = str(args.get("file_path",""))
content = str(args.get("content",""))
enc = str(args.get("encoding", default_encoding))
do_append = bool(args.get("append", False))
resolved = self._safe_join(base_dir, file_path)
if not resolved:
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_path", "file": file_path})
return {"result": False}
os.makedirs(os.path.dirname(resolved), exist_ok=True)
mode = "a" if do_append else "w"
with open(resolved, mode, encoding=enc) as f:
f.write(content)
if callback: callback.emit("tool_done", {"tool": name, "action": "write", "bytes": len(content)})
return {"result": True}
if callback: callback.emit("tool_error", {"tool": name, "reason": "unknown_action", "action": action})
return {"result": None}
except Exception as e:
if callback: callback.emit("tool_error", {"tool": name, "reason": "exception", "error": repr(e)})
return {"result": None}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
추천 워크플로
-
보안/정확도가 최우선이면 FileListToolNode + FileReadToolNode(+ 필요시 FileWriteToolNode)를 각각 만들고 ToolsMergeNode로 합친 뒤 AdvancedOptions.tools에 넣는다.
-
툴 제한이나 배선 단순화가 중요하면 FileManagerToolNode 하나만 넣어 action 기반으로 처리한다.
-
둘을 동시에 제공해, 같은 그래프에서 쉽게 교체해 비교할 수 있게 해두면 운영 중에도 유연하다.
이렇게 하면 네가 말한 “툴 노드는 각자 입력을 받아 동적 생성”도 충족하고, 필요 시 콜백을 통해 UI 업데이트도 받을 수 있어.
어차피 리스트로 줄줄이 넣을거라서 MergeNode는 필요없을거 같고 노드들만 개별로 추가해서 전체 코드 다시 정리해줘
# openai_comfy_nodes.py
# Python 3.10+
# Dependencies:
# pip install --upgrade openai httpx
#
# Nodes:
# 1) OpenAIChatNode (async)
# 2) OpenAICustomProviderNode
# 3) OpenAIAdvancedOptionsNode
# 4) OpenAICallbackNode (optional)
# 5) FileListToolNode
# 6) FileReadToolNode
# 7) FileWriteToolNode
#
# Design:
# - Async-only (AsyncOpenAI + httpx.AsyncClient).
# - Provider chosen by name from config.toml (OPENAI_PROVIDER_CONFIG or ./config.toml),
# or overridden via AdvancedOptions.
# - Parameters are parsed permissively (JSON or loose "key: value") and forwarded verbatim as **kwargs.
# - Tools are created per Tool node (no built-ins registry). Each node builds a ToolSpec
# with a closure that captures node inputs (e.g., base_dir, encoding) and optional callback.
# - Tool JSON shape: flat or nested (set in provider).
# - Tool calls are executed locally; responses/completions loop until no tool calls.
#
# Security notes:
# - File tools use _safe_join with an optional base_dir to avoid path traversal outside base_dir.
# - If you pass an empty base_dir, absolute paths are permitted (use with care).
from __future__ import annotations
import os
import re
import json
import asyncio
import signal
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol
# TOML loader
try:
import tomllib # Py3.11+
except Exception:
tomllib = None
try:
import tomli # Fallback
except Exception:
tomli = None
import httpx
from openai import AsyncOpenAI
# =========================
# Interrupt helpers
# =========================
class UserCancelledError(Exception):
"""Raised when the user has interrupted the processing."""
class InterruptUser:
"""Cooperative interrupt controller."""
def __init__(self) -> None:
self._event = asyncio.Event()
def trigger_interrupt(self) -> None:
self._event.set()
async def processing_interrupted(self) -> bool:
return self._event.is_set()
async def call_with_interrupt(awaitable, user: InterruptUser, poll: float = 0.2):
"""Await an I/O while polling interruption; cancel if interrupted."""
task = asyncio.create_task(awaitable)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=poll)
if task in done:
return task.result()
if await user.processing_interrupted():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
raise UserCancelledError("Interrupted by user.")
except Exception:
raise
# =========================
# Permissive parameters parsing
# =========================
def _try_json_loads(s: str) -> Optional[Any]:
try:
return json.loads(s)
except Exception:
return None
def _parse_scalar(v: str) -> Any:
t = v.strip()
if t.lower() in ("true", "false"):
return t.lower() == "true"
if t.lower() in ("null", "none"):
return None
try:
if re.match(r"^-?\d+$", t):
return int(t)
if re.match(r"^-?\d+\.\d+$", t):
return float(t)
except Exception:
pass
if (t.startswith("[") and t.endswith("]")) or (t.startswith("{") and t.endswith("}")):
j = _try_json_loads(t)
if j is not None:
return j
if "," in t:
parts = [p.strip() for p in t.split(",")]
return [_parse_scalar(p) if isinstance(p, str) else p for p in parts]
return t
def parse_loose_params(text: str) -> Dict[str, Any]:
"""
1) Try strict JSON.
2) Else parse lines like "key: value" or "key = value" with auto-typing.
Ignores empty lines and comment lines starting with # // ;
"""
if not text or not text.strip():
return {}
j = _try_json_loads(text)
if isinstance(j, dict):
return j
out: Dict[str, Any] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
continue
line = re.split(r"\s+#", line, maxsplit=1)[0].strip() # strip trailing comment after '#'
parts = re.split(r"\s*[:=]\s*", line, maxsplit=1)
if len(parts) != 2:
continue
key, val = parts[0].strip(), parts[1].strip()
if not key:
continue
norm_key = re.sub(r"[\s\-]+", "_", key.lower())
out[norm_key] = _parse_scalar(val)
return out
# =========================
# Provider config
# =========================
@dataclass
class ProviderProfile:
name: str
model: str
base_url: str
api_key: str
api: str # "responses" | "completions"
tool_json: str # "flat" | "nested"
default_parameters: Dict[str, Any] # forwarded as **kwargs
def _load_toml_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def load_providers_from_config(config_path: str) -> Dict[str, ProviderProfile]:
"""
[[api_provider]]
name = "OSS GPT (Responses + Flat)"
model = "gpt-4.1-mini"
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY" # or api_key = "sk-..."
api = "responses" # "responses" | "completions"
tool_json = "flat" # "flat" | "nested"
parameters = '''
tool_choice: auto
max_output_tokens: 1024
temperature: 0.7
top_p: 0.9
'''
"""
if not os.path.exists(config_path):
return {}
raw = _load_toml_bytes(config_path)
if config_path.lower().endswith(".toml"):
if tomllib is not None:
data = tomllib.loads(raw.decode("utf-8"))
elif tomli is not None:
data = tomli.loads(raw.decode("utf-8"))
else:
raise RuntimeError("TOML parser not available. Use Python 3.11+ or `pip install tomli`.")
else:
data = json.loads(raw.decode("utf-8"))
providers_data = data.get("api_provider") or data.get("api_providers")
if not isinstance(providers_data, list):
return {}
out: Dict[str, ProviderProfile] = {}
for p in providers_data:
name = p.get("name")
model = p.get("model")
base_url = p.get("base_url")
api_key = p.get("api_key")
api_key_env = p.get("api_key_env")
api = p.get("api")
tool_json = p.get("tool_json")
raw_params = p.get("parameters", {})
if not name or not model or not base_url or not api or not tool_json:
continue
if not api_key and api_key_env:
api_key = os.getenv(api_key_env, "")
if not api_key:
continue
if isinstance(raw_params, str):
params_dict = parse_loose_params(raw_params)
elif isinstance(raw_params, dict):
params_dict = raw_params
else:
params_dict = {}
out[name] = ProviderProfile(
name=name,
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return out
DEFAULT_CONFIG_PATH = os.getenv("OPENAI_PROVIDER_CONFIG", os.path.join(os.getcwd(), "config.toml"))
PROVIDER_CATALOG: Dict[str, ProviderProfile] = load_providers_from_config(DEFAULT_CONFIG_PATH)
# =========================
# Tool layer (dynamic, per-node)
# =========================
@dataclass
class ToolSpec:
name: str
description: str
parameters: Dict[str, Any]
func: Any # Callable[[Dict[str, Any]], Any]
class ToolJSONBuilder(Protocol):
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
...
class FlatToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "name": s.name, "description": s.description, "parameters": s.parameters} for s in specs]
class NestedToolJSONBuilder:
def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
return [{"type": "function", "function": {"name": s.name, "description": s.description, "parameters": s.parameters}} for s in specs]
def select_tool_builder(kind: str) -> ToolJSONBuilder:
k = (kind or "").strip().lower()
if k == "flat":
return FlatToolJSONBuilder()
if k == "nested":
return NestedToolJSONBuilder()
raise ValueError(f"Unknown tool_json: {kind}")
# =========================
# Optional callback sink
# =========================
@dataclass
class CallbackSink:
"""Lightweight event sink. You can attach a UI listener to consume .events."""
label: str = "OpenAI"
events: List[Dict[str, Any]] = None
def __post_init__(self):
if self.events is None:
self.events = []
def emit(self, event: str, payload: Dict[str, Any]):
rec = {"event": event, "payload": payload}
self.events.append(rec)
print(f"[{self.label}] {event}: {payload}")
# =========================
# Protocol runners (async), **kwargs passthrough
# =========================
class ResponsesRunner:
"""Async Responses loop; tool calls executed locally."""
@staticmethod
def _collect_text(resp: Any) -> str:
out = getattr(resp, "output", []) or []
texts: List[str] = []
for item in out:
if getattr(item, "type", "") == "message":
for c in getattr(item, "content", []) or []:
if getattr(c, "type", "") == "output_text":
texts.append(getattr(c, "text", ""))
if getattr(item, "type", "") == "output_text":
texts.append(getattr(item, "text", ""))
return "\n".join([t for t in texts if t])
@staticmethod
def _collect_calls(resp: Any) -> List[Dict[str, Any]]:
out = getattr(resp, "output", []) or []
calls: List[Dict[str, Any]] = []
for item in out:
t = getattr(item, "type", None)
if t == "function_call":
args = getattr(item, "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(item, "call_id", None),
"name": getattr(item, "name", None),
"arguments": args or {}})
if t == "message":
inner = getattr(item, "tool_calls", None)
if inner:
for c in inner:
nm = getattr(getattr(c, "function", {}), "name", None) if hasattr(c, "function") else getattr(c, "name", None)
args = getattr(getattr(c, "function", {}), "arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
calls.append({"call_id": getattr(c, "id", None),
"name": nm,
"arguments": args or {}})
return calls
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
base = {"model": model, "input": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
chunks: List[str] = []
guard = 0
while guard < 12:
guard += 1
chunks.append(self._collect_text(resp))
calls = self._collect_calls(resp)
if not calls:
break
for c in calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
out = self._exec_tool(c["name"], c["arguments"])
messages.append({"type": "function_call_output", "call_id": c["call_id"], "output": json.dumps(out, ensure_ascii=False)})
base = {"model": model, "input": messages, "tools": tools_json}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.responses.create(**args), user)
return "\n".join([t for t in chunks if t])
class CompletionsRunner:
"""Async Chat Completions loop; tool calls executed locally."""
def __init__(self, tools: List[ToolSpec]) -> None:
self.tools = {t.name: t for t in tools}
def _exec_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
spec = self.tools.get(name)
if not spec:
return {"error": f"Unknown tool: {name}"}
try:
return spec.func(args)
except Exception as e:
return {"error": repr(e)}
async def run(
self,
client: AsyncOpenAI,
model: str,
messages: List[Dict[str, Any]],
tools_json: List[Dict[str, Any]],
params: Dict[str, Any],
user: InterruptUser
) -> str:
base = {"model": model, "messages": messages, "tools": tools_json, "tool_choice": "auto"}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
chunks: List[str] = [msg.content or ""]
guard = 0
while getattr(msg, "tool_calls", None) and guard < 12:
guard += 1
tool_msgs: List[Dict[str, Any]] = []
for tc in msg.tool_calls:
if await user.processing_interrupted():
raise UserCancelledError("Interrupted by user.")
fn = tc.function.name
try:
fn_args = json.loads(tc.function.arguments or "{}")
except Exception:
fn_args = {}
out = self._exec_tool(fn, fn_args)
tool_msgs.append({"role": "tool", "tool_call_id": tc.id, "name": fn, "content": json.dumps(out, ensure_ascii=False)})
messages.append({"role": "assistant", "content": msg.content or "", "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
messages.extend(tool_msgs)
base = {"model": model, "messages": messages}
args = {**base, **(params or {})}
resp = await call_with_interrupt(client.chat.completions.create(**args), user)
msg = resp.choices[0].message
if msg.content:
chunks.append(msg.content)
return "\n\n".join([t for t in chunks if t])
# =========================
# Orchestrator
# =========================
def build_payload(provider: ProviderProfile, tools: List[ToolSpec]) -> Dict[str, Any]:
builder = select_tool_builder(provider.tool_json)
tools_json = builder.build(tools)
return {"tools_json": tools_json, "params": dict(provider.default_parameters or {})}
async def run_with_provider(
provider: ProviderProfile,
system_prompt: str,
user_prompt: str,
tools: List[ToolSpec],
params_override: Dict[str, Any],
user: InterruptUser
) -> str:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
built = build_payload(provider, tools)
tools_json = built["tools_json"]
params = built["params"]
if params_override:
params = {**params, **params_override} # right wins
hooks = {"request": [], "response": []}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), event_hooks=hooks) as http_client:
async with AsyncOpenAI(base_url=provider.base_url, api_key=provider.api_key, http_client=http_client) as client:
if provider.api == "responses":
runner = ResponsesRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
else:
runner = CompletionsRunner(tools)
return await runner.run(client, provider.model, messages, tools_json, params, user)
# =========================
# Nodes
# =========================
# -- Provider
class OpenAICustomProviderNode:
"""Create a provider profile directly from UI inputs; parameters forwarded verbatim as **kwargs."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {"default": "https://api.openai.com/v1"}),
"model": ("STRING", {"default": "gpt-4.1-mini"}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"api": (["responses", "completions"],),
"tool_json": (["flat", "nested"],),
},
"optional": {
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\nmax_output_tokens: 1024\ntemperature: 0.7\ntop_p: 0.9\n"
}),
}
}
RETURN_TYPES = ("OPENAI_PROVIDER",)
RETURN_NAMES = ("provider",)
FUNCTION = "build"
CATEGORY = "OpenAI/Provider"
def build(self, base_url: str, model: str, api_key: str, api: str, tool_json: str, parameters: str = ""):
params_dict = parse_loose_params(parameters or "")
provider = ProviderProfile(
name=f"custom::{model}",
model=model,
base_url=base_url,
api_key=api_key,
api=api,
tool_json=tool_json,
default_parameters=params_dict,
)
return (provider,)
# -- Callback sink
class OpenAICallbackNode:
"""Create a simple callback sink. Tools can emit(event, payload) to this sink."""
@classmethod
def INPUT_TYPES(cls):
return {"optional": {"label": ("STRING", {"default": "OpenAI"})}}
RETURN_TYPES = ("OPENAI_CALLBACK",)
RETURN_NAMES = ("callback",)
FUNCTION = "build"
CATEGORY = "OpenAI/Utils"
def build(self, label: str = "OpenAI"):
return (CallbackSink(label=label),)
# -- File tools (each is a separate node)
class FileListToolNode:
"""
Create a 'list files' tool. Chain multiple tool nodes by passing previous tools into tools_in.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "list_files_in_folder"}),
"description": ("STRING", {"default": "List files in a folder."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"return_full_paths": ("BOOLEAN", {"default": False}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", return_full_paths=False, callback=None):
schema = {
"type": "object",
"properties": {
"folder_path": {"type": "string", "description": "Folder path (relative to base_dir if set)."},
"pattern": {"type": "string", "description": "Optional glob, e.g. *.txt"},
"absolute": {"type": "boolean", "description": "Force absolute paths override."}
},
"required": ["folder_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
folder = str(args.get("folder_path",""))
pattern = str(args.get("pattern",""))
absolute = bool(args.get("absolute", return_full_paths))
resolved = self._safe_join(base_dir, folder)
if not resolved or not os.path.isdir(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_folder", "folder": folder})
return {"result": []}
import glob
g = os.path.join(resolved, pattern) if pattern else os.path.join(resolved, "*")
files = [p for p in glob.glob(g) if os.path.isfile(p)]
out = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
if callback: callback.emit("tool_done", {"tool": name, "count": len(out)})
return {"result": sorted(out)}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
class FileReadToolNode:
"""
Create a 'read text file' tool. Chain multiple tool nodes by passing previous tools into tools_in.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "read_text_file"}),
"description": ("STRING", {"default": "Read a UTF-8 text file."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"encoding": ("STRING", {"default": "utf-8"}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", encoding="utf-8", callback=None):
schema = {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "File path (relative to base_dir if set)."},
"encoding": {"type": "string", "description": "Override encoding."}
},
"required": ["file_path"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
path = str(args.get("file_path",""))
enc = str(args.get("encoding", encoding))
resolved = self._safe_join(base_dir, path)
if not resolved or not os.path.isfile(resolved):
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_file", "file": path})
return {"result": ""}
try:
with open(resolved, "r", encoding=enc) as f:
text = f.read()
if callback: callback.emit("tool_done", {"tool": name, "size": len(text)})
return {"result": text}
except Exception as e:
if callback: callback.emit("tool_error", {"tool": name, "reason": "read_failed", "error": repr(e)})
return {"result": ""}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
class FileWriteToolNode:
"""
Create a 'write text file' tool. Chain multiple tool nodes by passing previous tools into tools_in.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "write_text_file"}),
"description": ("STRING", {"default": "Write text to a file (creates dirs)."}),
},
"optional": {
"tools_in": ("OPENAI_TOOLS",),
"base_dir": ("STRING", {"default": ""}),
"encoding": ("STRING", {"default": "utf-8"}),
"append": ("BOOLEAN", {"default": False}),
"callback": ("OPENAI_CALLBACK",),
}
}
RETURN_TYPES = ("OPENAI_TOOLS",)
RETURN_NAMES = ("tools",)
FUNCTION = "append_tool"
CATEGORY = "OpenAI/Tools"
@staticmethod
def _safe_join(base_dir: Optional[str], path: str) -> Optional[str]:
if not base_dir:
return os.path.abspath(path)
base = os.path.abspath(base_dir)
target = os.path.abspath(os.path.join(base, path))
return target if os.path.commonpath([base, target]) == base else None
def append_tool(self, name, description, tools_in=None, base_dir="", encoding="utf-8", append=False, callback=None):
schema = {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "File path (relative to base_dir if set)."},
"content": {"type": "string", "description": "Text content to write."},
"encoding": {"type": "string", "description": "Override encoding."},
"append": {"type": "boolean", "description": "Append to file instead of overwrite."}
},
"required": ["file_path", "content"],
"additionalProperties": False
}
def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
path = str(args.get("file_path",""))
content = str(args.get("content",""))
enc = str(args.get("encoding", encoding))
do_append = bool(args.get("append", append))
resolved = self._safe_join(base_dir, path)
if not resolved:
if callback: callback.emit("tool_error", {"tool": name, "reason": "invalid_path", "file": path})
return {"result": False}
try:
os.makedirs(os.path.dirname(resolved), exist_ok=True)
mode = "a" if do_append else "w"
with open(resolved, mode, encoding=enc) as f:
f.write(content)
if callback: callback.emit("tool_done", {"tool": name, "bytes": len(content)})
return {"result": True}
except Exception as e:
if callback: callback.emit("tool_error", {"tool": name, "reason": "write_failed", "error": repr(e)})
return {"result": False}
spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl)
return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
# -- Advanced options aggregator
@dataclass
class AdvancedOptions:
tools: List[ToolSpec]
custom_provider: Optional[ProviderProfile]
system_prompt: Optional[str]
parameters: Dict[str, Any] # forwarded as **kwargs
class OpenAIAdvancedOptionsNode:
"""Aggregate tools, custom provider, system prompt, and parameters (verbatim **kwargs)."""
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"tools": ("OPENAI_TOOLS",),
"custom_provider": ("OPENAI_PROVIDER",),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"parameters": ("STRING", {
"multiline": True,
"default": "tool_choice: auto\n# e.g., max_tokens / max_output_tokens / top_p / temperature / stop\n"
}),
}
}
RETURN_TYPES = ("OPENAI_ADVANCED",)
RETURN_NAMES = ("advanced",)
FUNCTION = "build"
CATEGORY = "OpenAI/Options"
def build(self, tools=None, custom_provider=None, system_prompt: str = "", parameters: str = ""):
tlist: List[ToolSpec] = tools if isinstance(tools, list) else []
provider = custom_provider if isinstance(custom_provider, ProviderProfile) else None
sys_prompt = system_prompt if (system_prompt and system_prompt.strip()) else None
params_dict = parse_loose_params(parameters or "")
return (AdvancedOptions(tlist, provider, sys_prompt, params_dict),)
# -- Chat
class OpenAIChatNode:
"""
Async chat node:
- Provider chosen by name from config.toml (auto-refreshed), overrideable via AdvancedOptions.custom_provider
- Parameters: provider.default_parameters merged with AdvancedOptions.parameters (right wins)
- Executes tool-calling loop and returns final text
"""
@classmethod
def _provider_names(cls) -> List[str]:
global PROVIDER_CATALOG
PROVIDER_CATALOG = load_providers_from_config(DEFAULT_CONFIG_PATH)
return list(PROVIDER_CATALOG.keys()) or ["<no providers in config>"]
@classmethod
def INPUT_TYPES(cls):
provider_list = cls._provider_names()
return {
"required": {
"user_prompt": ("STRING", {"multiline": True, "default": "List files under ./samples, choose one text file, read it, and summarize in 2-3 sentences."}),
"provider_name": (provider_list, {"default": provider_list[0]}),
},
"optional": {
"advanced": ("OPENAI_ADVANCED",),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response_text",)
FUNCTION = "run"
OUTPUT_NODE = False
CATEGORY = "OpenAI/Chat"
async def run(self, user_prompt: str, provider_name: str, advanced=None):
# Resolve provider
global PROVIDER_CATALOG
provider = PROVIDER_CATALOG.get(provider_name)
if advanced and isinstance(advanced, AdvancedOptions) and advanced.custom_provider:
provider = advanced.custom_provider
if not provider:
return ("[OpenAIChatNode] Error: provider not found. Check config.toml or AdvancedOptions.",)
# System prompt
system_prompt = "You are a helpful assistant that uses tools when needed."
if advanced and isinstance(advanced, AdvancedOptions) and advanced.system_prompt:
system_prompt = advanced.system_prompt
# Tools
tools: List[ToolSpec] = []
if advanced and isinstance(advanced, AdvancedOptions) and advanced.tools:
tools = advanced.tools
# Merge parameters: provider defaults overridden by advanced
params_override: Dict[str, Any] = {}
if advanced and isinstance(advanced, AdvancedOptions) and advanced.parameters:
params_override = advanced.parameters
# Interrupt
user = InterruptUser()
loop = asyncio.get_running_loop()
try:
loop.add_signal_handler(signal.SIGINT, user.trigger_interrupt)
loop.add_signal_handler(signal.SIGTERM, user.trigger_interrupt)
except Exception:
pass
try:
text = await run_with_provider(
provider=provider,
system_prompt=system_prompt,
user_prompt=user_prompt,
tools=tools,
params_override=params_override,
user=user,
)
return (text or "",)
except UserCancelledError:
return ("[OpenAIChatNode] Interrupted by user.",)
except Exception as e:
return (f"[OpenAIChatNode] Error: {repr(e)}",)
# =========================
# ComfyUI registration
# =========================
NODE_CLASS_MAPPINGS = {
"OpenAIChatNode": OpenAIChatNode,
"OpenAICustomProviderNode": OpenAICustomProviderNode,
"OpenAIAdvancedOptionsNode": OpenAIAdvancedOptionsNode,
"OpenAICallbackNode": OpenAICallbackNode,
"FileListToolNode": FileListToolNode,
"FileReadToolNode": FileReadToolNode,
"FileWriteToolNode": FileWriteToolNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OpenAIChatNode": "OpenAI Chat (Async)",
"OpenAICustomProviderNode": "OpenAI Custom Provider",
"OpenAIAdvancedOptionsNode": "OpenAI Advanced Options",
"OpenAICallbackNode": "OpenAI Callback Sink",
"FileListToolNode": "Tool: List Files",
"FileReadToolNode": "Tool: Read Text File",
"FileWriteToolNode": "Tool: Write Text File",
}