openai package를 사용하는 테스트 코드 작성할거야. 목표: 2가지 모델에 대해 2가지 api type(completions, responses)에 대해 function calling 테스트를 하고 싶어. 총 4개 조합에 대해 설정할 수 있게 코드 작성 하자. 툴은 간단히 특정 폴더의 파일 조회, 특정 폴더의 파일 읽기 두 가지 툴을 구현할거야 2개 모델에 대한 엔드포인트, api 키, 모델명을 코드 내에서 설정할 수 있도록 할 것.

좋아! 아래는 “두 모델 × 두 API 타입(completions, responses) × 함수 호출(function calling)” 조합을 한 파일에서 모두 테스트할 수 있는 파이썬 스크립트야.
요구사항 정리대로

  1. 두 가지 파일 관련 툴(폴더 내 파일 목록 조회, 파일 읽기)

  2. 2개 모델에 대한 엔드포인트·API 키·모델명을 코드 내에서 설정 가능

  3. 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()

사용 방법 간단 정리

  1. 환경 변수 세팅(권장)
  • 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

  1. 테스트 파일 준비
  • 프로젝트 루트에 samples 폴더를 만들고, 텍스트 파일을 1개 이상 넣는다. 예: samples/readme.txt
  1. 실행
  • 네 조합 모두:
    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)했지만 디코딩 실패 시 빈 문자열을 반환해 모델이 적절히 안내하도록 했어.

문서 참고 링크

필요하면

  • 모델명만 바꿔서(예: 최신 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)

핵심 차이 정리 단계별

  1. 도구 정의 자체는 필드 구조가 동일(type=function, function.name/description/parameters)하되, Responses 쪽 예시에서는 strict를 켜서 JSON Schema를 엄격히 지키게 하는 패턴이 널리 쓰인다. 그래서 스키마가 “조금 다르게 생긴 것처럼” 보일 수 있다. OpenAI 플랫폼+1

  2. Chat Completions는 messages 배열에 assistant.tool_calls가 나오고, 각 call.id에 대응하는 tool 메시지를 넣어 다시 chat.completions.create를 호출한다.

  3. 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

포인트 요약

  1. CHAT_STYLE_TOOLS와 RESPONSES_STYLE_TOOLS를 분리해 두었고, Responses 루프는 네가 붙여준 예시처럼 input_list에 function_call과 function_call_output을 누적하는 방식으로 구현했어. 일부 문서/예시에선 strict를 켜 JSON 스키마를 강제하니 필요하면 해당 키를 켜면 돼. OpenAI 플랫폼+1

  2. 일부 조합은 의도적으로 호환되지 않아 에러가 날 수 있어(예: Chat Completions에 Responses 스타일 tools). 모든 run은 try/except로 감싸 실패해도 나머지가 이어진다.

  3. 최신 차이점과 권장 흐름은 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조합)을 모두 만족하는 단일 실행 스크립트를 준비했어. 다음을 보장해.

  1. Responses API에서 여러 번 호출(툴 호출 발생 시 루프)하며, 매 스텝의 대화 전개를 콘솔에 모두 출력.

  2. 각 조합마다 “입력 전체”와 “출력 전체”를 JSON으로 파일로 저장. 콘솔에선 색상으로 (입력 / 툴요청 / 출력) 구분.

  3. Structured JSON 파싱 버전과 일반 버전 각각 제공.

  4. 에러는 조합별로 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()

사용법 요약

  1. 환경 변수 설정(예시)
  • 모델 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

  1. 샘플 준비
    프로젝트 루트에 samples 폴더를 만들고 텍스트 파일을 1개 이상 넣어둔다.

  2. 실행 예시

  • 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)를 대체하는 완전판이야. 바뀐 점만 빠르게 요약하고 바로 코드 줄게.

변경 요약

  1. Structured 파싱 모델 교체
  • Pydantic 모델을 TaskAnswer로 정의하고 필드를 filename, contents 두 개로 통일.

  • contents는 “읽은 파일의 2~3문장 요약”을 넣도록 system/instructions에 명확히 지시.

  1. Structured 모드에서 결과 JSON 저장
  • 각 조합 폴더에 result.json으로 {"filename","contents"} 저장.

  • 기존처럼 input.json, outputs.json도 계속 저장.

  1. 콘솔 색상 출력은 동일하게 유지
  • 입력(CYAN), 툴 요청(YELLOW), 출력(GREEN)으로 구분.
  1. 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가 반드시 들어가야 해.

수정 방향 요약 단계별

  1. tools_variant와 structured 여부에 따라 툴 JSON에 strict를 주입하는 헬퍼를 만든다.

  2. Chat 스타일일 땐 tool["function"]["strict"]=True, Responses 스타일일 땐 tool["strict"]=True를 넣는다.

  3. 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) + 레지스트리 + 어댑터” 조합으로 깔끔하게 설계한 파이썬 예시를 줄게.

핵심 아이디어 요약(간결):

  1. Strategy 패턴: 서로 다른 호출 방식 차이를 캡슐화
  • ResponsesStrategy(GPT OSS, responses 호출, “신규/평평한” tool JSON)

  • CompletionsStrategy(Qwen, chat.completions 호출, “과거/중첩” tool JSON)

  1. ToolRegistry + ToolSchemaBuilder(Factory):
  • ToolRegistry는 파이썬 함수를 등록/실행

  • ToolSchemaBuilder가 등록된 툴을 각 엔진이 요구하는 JSON 모양으로 변환

    • NewToolJSONBuilder: responses용 flat JSON

    • OldToolJSONBuilder: completions용 nested JSON

  1. Output 어댑터(단순화):
  • 두 전략은 모두 최종적으로 str을 반환. 내부의 tool_call 파싱 차이는 전략에서 숨김.
  1. 설정 주입:
  • 엔드포인트·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)”만 선언하도록 만드는 거야. 실행 전략은 조합에 따라 자동 선택·실행되며, 실패 시 합리적으로 폴백해.

아래 순서로 설명하고 바로 동작 가능한 예시 코드를 제공할게.

  1. 설계 요점
  • 모델 무관 추상화

    • 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로 툴 구현/등록을 분리

    • 툴 함수 추가/변경이 모델/프로토콜 코드에 전혀 영향 없도록
  1. 실제 동작 흐름
  • UI 콤보박스에서 “모델 키”만 선택 → Orchestrator.run(model_key, task)

  • Orchestrator는 ModelCatalog에서 프로필 조회 → Plan 결정 → 해당 Strategy와 ToolSchemaBuilder를 결합해 실행

  • responses 프로토콜은 function_call_output 루프, completions 프로토콜은 tool 메시지 루프 수행

  • 최종 결과는 일반 텍스트(plain)로 통일

  1. 코드
    아래 파일 하나로 실행 가능. 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()
  1. 이 설계가 확장에 유리한 이유
  • 새 모델 추가 시 변경 범위가 매우 작음

    • ModelCatalog에 ModelProfile 1줄 추가(이름/엔드포인트/키, 캡능/선호)

    • 조합을 몰라도 OK: preferred_plans 비우면 Orchestrator가 표준 우선순위로 자동 시도

  • 조합 폭발에도 견고

    • Plan을 독립 타입으로 관리해서 Protocol × ToolSchema를 명시적으로 표기

    • 실패 시 다음 Plan으로 폴백하는 일관된 로직

  • 관심사 분리

    • ToolRegistry/ToolSpec: 툴 구현과 파라미터 스키마만 관리

    • ToolJSONBuilder: JSON 모양만 담당(Flat/Nested 추가 쉬움)

    • ProtocolStrategy: 호출/루프/파싱 차이만 담당

    • Orchestrator: 모델 선택과 플랜 선정/폴백만 담당

  1. 커스터마이징 힌트
  • 모델별 “도구 정책”이 다르면 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가 다름

참고 문서

왜 이렇게 api를 짜증나게 만들었을까

그 마음 100% 이해해. 엔드포인트마다 이름이 미묘하게 다르고, 모델마다 지원/비지원이 달라서 매번 “이건 max_output_tokens였지? 아니면 max_tokens였나…?” 하게 만들죠.

왜 이런 일이 생기냐(현실적인 배경)

  1. 레거시와 진화가 동시에 굴러감
    초기 Chat Completions의 설계가 이미 광범위하게 쓰인 뒤, Responses 계열이 “더 범용(멀티모달·툴·스트리밍·상태전달)” 관점으로 나왔어요. 과거 API를 깨지 않으려니 파라미터/컨테이너 이름이 달라졌습니다.

  2. 모델 계열별 제약이 달라요
    “추론형(reasoning) 모델”은 토큰·추론깊이 제어가 별도라 max_completion_tokens, reasoning.effort 같은 필드가 추가되고, 일반 채팅 모델과 동작이 달라요.

  3. 안전·비용·속도 정책이 독립적으로 변함
    각 엔드포인트가 검열·중단·비용제어 사양을 따로 발전시키다 보니 교집합이 어긋납니다.

  4. 도구(tool) 생태계의 빠른 변화
    함수 호출 스키마가 “nested” → “flat”로, 혹은 strict 모드 추가처럼 사용성 개선을 반복하면서 예시와 실제가 엇갈리는 시기가 생겨요.

그래서 어떻게 덜 짜증나게 쓰냐(실전 팁)

  1. 어댑터 + 전략 패턴으로 API 차이를 가둬두기
    호출부엔 “내가 원하는 공통 파라미터”만 쓰고, 엔드포인트별로 맞는 실제 파라미터로 변환하는 얇은 레이어를 둡니다.

  2. 파라미터 정규화기(Parameter Normalizer)
    length, temp, top_p, stop, reasoning_effort 같은 “중립 이름”을 입력받아 completions/responses/모델종류에 맞춰 매핑합니다.

  3. 툴 스키마 빌더
    동일한 ToolRegistry를 두고, “flat”과 “nested” 스키마로 빌드만 갈라서 뿌립니다.

  4. 기능 감지와 폴백
    모델 프로필에 capabilities를 선언하고, 우선 시도할 조합 목록을 정해 실패 시 다음 조합으로 자동 폴백합니다.

  5. 계약 테스트(작은 매트릭스 러너)
    대표 프롬프트 하나로 “모델×프로토콜×툴스키마×파라미터세트”를 돌려 빨갛게 실패하는 조합을 미리 파악해둡니다.

아래는 “딱 이것만” 넣으면 스트레스가 급감하는 최소 어댑터 예시야. 비동기 기준, 호출부는 공통 파라미터만 건네고 내부에서 알아서 매핑합니다.

# 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

마이그레이션 체크리스트

  1. 호출부엔 neutral 파라미터만 노출한다(length, temperature, reasoning_effort 등).

  2. 엔진/모델 선택에 따라 ParamNormalizer가 실제 필드로 변환한다.

  3. 모델 능력 플래그(예: is_reasoning_model)는 ModelProfile에 넣고 Orchestrator가 주입한다.

  4. 실패 시 다른 Plan으로 폴백한다(Responses+flat → Completions+nested 같은 순서).

  5. 계약 테스트를 CI에 넣어 눈으로 확인하기 전에 “어느 조합이 깨졌는지” 자동으로 경고받는다.

API의 불일치는 우리 잘못이 아니니, 작은 어댑터/전략 레이어로 “짜증”을 코드 경계 밖에 가둬두는 게 정답이에요. 이 레이어 한 번 만들어두면 새 모델 10개가 들어와도 호출부는 거의 손댈 일이 없어집니다.

근데 너가 말한것도 못 믿겠어. 실제 내 서빙 환경과 api 패키지 설치 환경에서 테스트해봐야할 것 같아. 그런데 top_p 같은 것은 테스트해봐도 이게 반영이 된 건지 안 된 건지 내가 알 수가 있나?

완전 공감. 파라미터가 “들어가긴 했는지, 모델이 실제로 반영했는지”는 말만으론 못 믿죠. 그래서 실제 서빙 환경에서 직접 검증하는 비동기 테스트 하네스를 줘요. 핵심은 두 축입니다.

  1. 전송 검증: HTTP 요청 바디에 top_p, temperature 등이 실제로 포함되는지 로깅

  2. 효과 검증: 동일 프롬프트로 여러 번 샘플링하여 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())

어떻게 해석하면 되나

  1. requests__.json을 열어 실제 요청 바디에 top_p, temperature, max__ 가 들어갔는지 먼저 확인합니다. 이건 “전송은 됐다”를 보장합니다.

  2. results_*.json의 stats를 봅니다. top_p가 커질수록 pairwise_jaccard_3gram, avg_ttr, unique_ratio가 보통 증가합니다. 변동이 거의 없다면 모델이 top_p를 무시하는지, temperature가 너무 낮은지, 프롬프트가 지나치게 결정적인지(사실상 한 가지 답) 점검하세요.

  3. 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 등)가 ‘덕 타이핑 기반의 구조적 서브타이핑’을 적용하도록 만드는 거야.

핵심만 단계별로 정리할게.

  1. Protocol이 뭐고, 상속하면 뭐가 달라지나
  • Protocol을 상속한 클래스는 인터페이스(규격)로 취급돼.

  • 이 인터페이스를 “구현”하는 쪽은 굳이 상속할 필요가 없어. 같은 시그니처의 메서드·속성을 갖고 있으면 타입체커가 자동으로 호환된다고 본다.

  • 즉, 이름으로 맞추는 덕 타이핑을 정적 타입 수준에서 안전하게 쓰게 해준다.

  1. ABC(Abstract Base Class)와의 차이
  • ABC는 명시적 상속과 추상 메서드 구현이 필요해. 상속관계가 중심이야.

  • Protocol은 구조적(subtype-by-structure)이라 상속 없이도 규격만 맞으면 통과야. 플러그인 구조나 외부 라이브러리 어댑터처럼 “손댈 수 없는 타입들”을 끼워 넣기 좋다.

  • 런타임 강제력은 기본적으로 없음. 주로 정적 타입체킹 단계에서 이점이 크다.

  1. 우리 코드에서 왜 썼나
  • ToolJSONBuilder, ArgAdapter, ProtocolStrategy 같은 “역할”을 인터페이스로 분리했지.

  • 새 벤더·새 전략을 추가할 때 굳이 공통 베이스 클래스를 상속하지 않아도, 같은 메서드 시그니처만 맞추면 곧바로 끼워 넣을 수 있어.

  • 결과적으로 의존성 역전(DI)과 확장성(새 구현체 추가)에 유리해.

  1. 최소 예시
    아래에서 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
  1. 런타임에서 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)  # 가능해짐 (정교한 보장은 아님)
  1. 언제 Protocol이 특히 유용한가
  • 플러그인/전략/어댑터 패턴: 서드파티 구현체를 쉽게 꽂을 때

  • 테스트 더블 주입: 목/스텁을 상속 없이 바로 넣고 싶을 때

  • 멀티 벤더 지원: 서로 다른 SDK 객체라도 같은 표면 API만 맞춰주면 교체 가능

  1. 주의할 점
  • 런타임 강제력이 거의 없으니, 타입 안전성은 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",
}

설치와 사용 방법 요약

  1. 파일 배치
  • 위 코드를 ComfyUI/custom_nodes/openai_comfy_nodes.py로 저장.

  • OpenAI 파이썬 SDK와 httpx 설치: pip install --upgrade openai httpx

  1. 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"
  1. 워크플로 구성
  • 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",
}

빠른 사용 가이드

  1. ComfyUI/custom_nodes/ 아래에 파일 저장 후 재시작.

  2. 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
"""
  1. 워크플로우에서 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",
}

어떻게 쓰면 되나

  1. 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으로 이전 툴 리스트를 받아 이어 붙인다.

  1. Callback 연결
    OpenAICallbackNode를 하나 만들고, Tool 노드의 callback 입력에 연결하면 툴 실행 때마다 emit이 호출되어 콘솔/이벤트 리스트가 갱신된다. 필요하면 이 Sink를 받아 다른 노드에서 이벤트를 확인하는 확장도 가능하다.

  2. 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"]

  1. 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)을 추천하나

  1. 모델 라우팅 정확도
    툴 명과 파라미터가 명확할수록 LLM이 올바른 툴을 고르는 확률이 높아져. 하나의 툴에 action 필드를 두면 모델이 잘못된 파라미터 조합을 넣거나 action을 틀리게 고를 가능성이 증가.

  2. 스키마 단순성/검증 용이성
    각 툴이 자기 파라미터만 엄격히 받으니 검증이 간단하고, 에러 메시지도 명확해.

  3. 권한/보안 제어
    예를 들어 프로덕션에서는 쓰기 툴만 막고 싶을 수 있어. 툴이 분리돼 있으면 선택적으로 비활성화하기 쉽다.

  4. 병렬성/성능
    리스트→여러 파일 읽기 같은 플로우에서 모델이 병렬 툴 콜을 만들 수도 있어. 액션 하나짜리 거대 툴보다 병렬화가 자연스럽다.

언제 단일 멀티액션 툴이 나은가

  1. 툴 갯수 제한이 빡빡한 모델/플랫폼을 쓰는 경우
    한 개 툴로 액션을 분기하면 툴 수를 줄일 수 있어.

  2. 액션들이 강하게 결합되어 있거나 같은 전처리를 공유하는 경우
    예를 들어 동일한 권한·캐시·세션 핸들러를 반드시 공유해야 한다면 하나로 묶어 이점이 있다.

  3. 그래프 단순화가 중요한 경우
    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],)

추천 워크플로

  1. 보안/정확도가 최우선이면 FileListToolNode + FileReadToolNode(+ 필요시 FileWriteToolNode)를 각각 만들고 ToolsMergeNode로 합친 뒤 AdvancedOptions.tools에 넣는다.

  2. 툴 제한이나 배선 단순화가 중요하면 FileManagerToolNode 하나만 넣어 action 기반으로 처리한다.

  3. 둘을 동시에 제공해, 같은 그래프에서 쉽게 교체해 비교할 수 있게 해두면 운영 중에도 유연하다.

이렇게 하면 네가 말한 “툴 노드는 각자 입력을 받아 동적 생성”도 충족하고, 필요 시 콜백을 통해 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",
}

근데 processing_interrupted()라고 인터럽트 여부 확인하는 함수가 이미 있어. 근데 이건 async 함수는 아니야. 그럴 경우 그냥 is_set 부분 대신 processing_interrupted() 를 넣어서 리턴해버려도 되나

짧은 답: 네. 그 함수가 동기(synchronous)로 즉시 bool을 돌려주는 형태라면, 이벤트 루프를 막지 않으므로 그대로 호출해서 판정해도 됩니다. 핵심은 “절대 블로킹하지 않는 빠른 함수”여야 한다는 점뿐이에요.

실전 적용 방법은 두 가지가 있습니다.

  1. 인터럽트 체커를 인자로 주입하는 방식(추천)
  • call_with_interrupt와 러너 루프가 공통으로 쓸 수 있는 동기 체크 함수를 받도록 바꿉니다.
# 1) interrupt checker signature: Callable[[], bool]
from typing import Callable

class UserCancelledError(Exception): ...

async def call_with_interrupt(awaitable, is_interrupted: Callable[[], bool], poll: float = 0.2):
    task = asyncio.create_task(awaitable)
    try:
        while True:
            done, _ = await asyncio.wait({task}, timeout=poll)
            if task in done:
                return task.result()
            if is_interrupted():                 # sync check
                task.cancel()
                try:
                    await task
                except asyncio.CancelledError:
                    pass
                raise UserCancelledError("Interrupted by user.")
    except Exception:
        raise

사용 예시:

# 어디선가 제공되는 동기 함수라고 가정
# def processing_interrupted() -> bool: ...

resp = await call_with_interrupt(client.responses.create(**args), is_interrupted=processing_interrupted)

# 루프 내부도 동일하게
if is_interrupted():
    raise UserCancelledError(...)

러너 클래스 내부 수정 포인트:

class ResponsesRunner:
    async def run(..., is_interrupted: Callable[[], bool]) -> str:
        resp = await call_with_interrupt(client.responses.create(**args), is_interrupted)
        ...
        for c in calls:
            if is_interrupted():                 # 동기 호출
                raise UserCancelledError(...)
            ...
        resp = await call_with_interrupt(client.responses.create(**args), is_interrupted)

CompletionsRunner도 동일하게 if is_interrupted(): ...로 바꾸면 됩니다.

  1. 기존 구조 유지하면서 얇은 래퍼로 통일하기
  • 이미 await user.processing_interrupted() 형태로 호출하는 코드가 곳곳에 있다면, 동기 함수를 감싸는 작은 어댑터를 만들어 비동기처럼 보이게 만들 수도 있습니다.
import inspect

def make_async_checker(sync_fn):
    async def _check():
        return bool(sync_fn())
    return _check

# 주입 시
async_is_interrupted = make_async_checker(processing_interrupted)

# 기존 코드 유지 가능:
if await async_is_interrupted():
    ...
resp = await call_with_interrupt(client.responses.create(**args), user=SomeUser(async_is_interrupted))

하지만 첫 번째 방법처럼 “동기 체크 함수를 그대로 받는” 형태가 더 단순하고, 오타 없이 일관되게 쓰기 쉬워서 권장합니다.

주의사항

  • processing_interrupted()는 반드시 논블로킹이어야 합니다. 파일 I/O나 락 대기 같은 동작이 들어가면 이벤트 루프가 멈춥니다.

  • 다른 스레드에서 인터럽트를 건다면, 내부 상태는 스레드 세이프하게 관리하세요. 가장 쉬운 구현은 threading.Event를 쓰고 .is_set()를 반환하는 래퍼를 두는 것입니다.

  • 체크 주기(poll 간격)는 200ms 정도가 적당하지만, 더 민감하게 끊고 싶으면 poll=0.05 등으로 조절하면 됩니다.

정리하면, 네. is_set() 대신 processing_interrupted()를 그대로 호출해도 되고, 그 방향이 오히려 깔끔합니다. 위의 패턴대로 call_with_interrupt와 루프 내부의 인터럽트 체크를 동기 호출로 바꿔주면 됩니다.

모델별로 기본 system 프롬프트를 넣어두는게 더 성능이 좋을까?

짧은 결론부터 말하면, “모델별 기본 system 프롬프트”를 가볍게라도 넣어두면 일관성과 도구 호출 정확도가 꽤 올라가는 경우가 많습니다. 다만 무겁고 장황한 프롬프트는 역효과가 날 수 있어요. 그래서 모델 특성에 맞춘 짧은 기본 프롬프트를 두고, 작업마다 필요할 때만 덧붙이는 방식을 권합니다.

왜 도움이 되나

  1. 도구 호출 안정화: 툴을 언제 써야 하는지, 파라미터를 어떻게 채워야 하는지 명시하면 잘못된 함수 호출이 줄어듭니다.

  2. 톤·형식 일관성: 응답 길이, 언어, 형식(예: 코드 우선, 간결 요약 등)을 통제해 재시도를 덜 합니다.

  3. 모델 편차 흡수: 같은 요청이라도 모델마다 안전·형식 기본값이 다른데, 기본 규칙을 박아두면 편차가 줄어요.

언제 해가 되나

  1. 너무 길고 일반적인 프롬프트: 토큰 낭비 + 모델이 핵심 규칙을 놓칩니다.

  2. 작업별 요구와 충돌: 기본 프롬프트가 “간결 요약”인데 사용자가 “장문 설명”을 원하면 품질이 떨어집니다.

  3. 벤더의 내부 프롬프트와 충돌: 지나치게 규제하면 모델이 과도하게 방어적이 되기도 합니다.

실전 가이드라인

  1. 길이는 짧게: 3~6문장 수준으로 핵심 규칙만.

  2. 툴 사용 규칙을 분명히: “파일 읽기/쓰기/리스트 도구를 우선 사용, 추측 금지, 파라미터는 스키마대로”.

  3. 출력 규칙을 분명히: “한국어로 간결히”, “중간 사고과정 노출 금지”, “필요 시 단계적 요약”.

  4. 모델별 미세 튜닝:

    • Responses 계열: “툴 호출 후 추가 질의가 필요하면 후속 호출 제안” 같이 대화 루프를 의식.

    • Chat Completions 계열: 메시지·tool_calls 포맷을 더 엄격히 지시.

  5. 태스크별 오버레이: 기본 프롬프트는 최소 규칙, 작업별 요구는 AdvancedOptions.system_prompt로 덧붙이기.

추천 템플릿 예시(가볍게)

  • 공통 최소 템플릿

    1. 사용 가능한 도구: list_files_in_folder, read_text_file, write_text_file.

    2. 추측 금지: 파일 내용이 필요하면 반드시 read_text_file을 호출.

    3. 보안: base_dir 밖 경로는 요청하지 않음.

    4. 출력: 한국어, 간결, 불필요한 사족 금지, 사고과정 노출 금지.

  • Responses 모델용 기본 템플릿 샘플
    “너는 파일 도구를 적시에 호출하는 조력자다. 파일 내용을 추측하지 말고, 필요하면 list→read→요약 순으로 툴을 호출해라. base_dir 밖 경로는 사용하지 않는다. 답변은 한국어로 간결하게 작성한다. 중간 사고과정은 노출하지 말고 최종 답만 제공한다.”

  • Chat Completions 모델용 기본 템플릿 샘플
    “너는 함수 호출 중심으로 작업한다. 도구의 매개변수는 스키마에 맞게 채워라. 파일 내용이 필요하면 반드시 read_text_file을 호출한 뒤 그 결과로만 답해라. base_dir 밖 경로는 사용하지 않는다. 한국어로 간결히 답하고, 불필요한 설명은 생략한다.”

구조에 넣는 방법(지금 네 노드 설계 기준)

  1. config.toml에 provider별 기본 system_prompt_default를 추가해 두고, OpenAIChatNode에서 우선순위를 정합니다.
    우선순위 권장: AdvancedOptions.system_prompt > Chat 노드 입력 > Provider.system_prompt_default > 아주 짧은 내장 디폴트.

  2. 모델별로 다른 기본 프롬프트를 설정해 두세요. 예: Qwen(Completions)은 도구 파라미터 엄격, GPT-OSS(Responses)는 대화 루프와 후속 호출 안내를 더 명시.

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"
system_prompt_default = """
도구를 적시에 호출하고, 파일 내용을 추측하지 않는다.
base_dir 밖 경로는 사용하지 않는다.
한국어로 간결히, 사고과정은 노출하지 않는다.
"""

[[api_provider]]
name = "Qwen (Completions + Nested)"
model = "qwen-plus"
base_url = "https://api.somevendor.com/v1"
api_key_env = "QWEN_API_KEY"
api = "completions"
tool_json = "nested"
system_prompt_default = """
함수 호출 중심으로 작업한다. 파라미터는 스키마에 정확히 맞춘다.
파일 내용이 필요하면 반드시 read_text_file을 호출한다.
한국어로 간결히, 사고과정은 노출하지 않는다.
"""

A/B 체크 포인트

  1. 툴 호출 정확도: 잘못된 함수명·누락 파라미터 비율

  2. 재시도율/라운드 수: 후속 호출 횟수

  3. 토큰 사용량과 지연시간: 기본 프롬프트 길이에 민감

  4. 사람 선호도: 작업별 짧은 평가 폼으로 수집

최종 권장

  • 네. 모델별 기본 system 프롬프트를 “짧고 규칙 위주”로 두는 게 보통 더 좋습니다.

  • 길게 쓰지 말고, 툴 사용 정책·보안·출력 형식만 고정하세요.

  • 작업 특화 요구는 AdvancedOptions.system_prompt로 얹는 2단 구성으로 운용하면, 모델 교체·추가가 생겨도 유지 관리가 쉽습니다.

너무 지금 툴 세팅에 편향된 내용 같은데, 실제론 수많은 툴이 사용될 수 있어 그때그때

좋은 포인트야. “툴이 몇 개 정해져 있다”는 가정에서 벗어나서, 툴이 수십·수백 개로 늘어나고 매 호출마다 달라지는 상황을 견디는 구조로 바꿨어. 핵심은 두 가지야.

  1. 모델/작업에 의존하지 않는 툴-불가지론 설계
  • 툴은 노드별로 동적으로 생성. 파일 툴뿐 아니라 임의의 파이썬 함수도 툴로 래핑(PythonCallableToolNode).

  • ToolSpec에 메타데이터(category, tags)를 추가해 대규모 툴을 분류·선별 가능.

  1. 런타임 자동 최적화(프롬프트/툴 단):
  • 툴이 너무 많을 때 모델에 모두 던지지 않고, user_prompt 기반 휴리스틱으로 관련 툴만 선별(tool_filter).

  • 시스템 프롬프트에 툴 전체를 붙이지 않고, “자동 툴 브리핑(auto_tool_brief)”로 상위 몇 개만 짧게 요약해 첨부.

  • 이 두 기능은 AdvancedOptions에서 온/오프로 제어.

아래는 전체 코드(기존 노드 + 일반화 + 자동 브리핑/선별 + 임의 파이썬 툴 래퍼 포함). 그대로 교체해서 쓸 수 있어.

# 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
#   8) PythonCallableToolNode  <- generic: wrap any module:function as a tool
#
# What changed (tool-agnostic & scalable):
# - ToolSpec now has metadata (category, tags).
# - Heuristic tool filtering: choose K most relevant tools to the prompt.
# - Auto tool briefing: short, model-friendly summary of selected tools is appended
#   to the system prompt to stabilize tool use without bloating the prompt.
# - Parameters from config/advanced are forwarded verbatim as **kwargs (no adapters).
# - Optional callback sink receives tool lifecycle events.

from __future__ import annotations

import os
import re
import json
import asyncio
import signal
import importlib
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, Callable

# 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
    system_prompt_default: Optional[str] = None  # per-model default system prompt

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
      temperature: 0.5
    '''
    system_prompt_default = """
파일·네트워크  다양한 도구를 적시에 호출하되, 스키마에 맞게 파라미터를 채운다.
출력은 간결한 한국어로 제공한다. 사고과정은 노출하지 말고 최종 답을 제공한다.
"""
    """
    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", {})
        sys_default = p.get("system_prompt_default")

        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,
            system_prompt_default=sys_default,
        )
    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]
    category: Optional[str] = None
    tags: Optional[List[str]] = None

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]]:
        items = []
        for s in specs:
            item = {"type": "function", "name": s.name, "description": s.description, "parameters": s.parameters}
            items.append(item)
        return items

class NestedToolJSONBuilder:
    def build(self, specs: List[ToolSpec]) -> List[Dict[str, Any]]:
        items = []
        for s in specs:
            item = {"type": "function", "function": {"name": s.name, "description": s.description, "parameters": s.parameters}}
            items.append(item)
        return items

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. Attach a UI listener to consume .events if needed."""
    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}")

# =========================
# Tool scaling utilities (filter + briefing)
# =========================

def _tokenize(text: str) -> List[str]:
    return [t for t in re.split(r"[^a-zA-Z0-9가-힣_]+", (text or "").lower()) if t]

def score_tool_relevance(user_prompt: str, tool: ToolSpec) -> float:
    # Simple lexical overlap on name, description, parameter keys, category, tags
    uq = set(_tokenize(user_prompt))
    fields = [
        tool.name,
        tool.description,
        " ".join(list(tool.parameters.get("properties", {}).keys()) if isinstance(tool.parameters, dict) else []),
        tool.category or "",
        " ".join(tool.tags or []),
    ]
    ut = set(_tokenize(" ".join(fields)))
    if not uq or not ut:
        return 0.0
    inter = len(uq.intersection(ut))
    return inter / (len(uq) ** 0.5 + len(ut) ** 0.5)

def filter_tools(user_prompt: str, tools: List[ToolSpec], mode: str, max_tools: int) -> List[ToolSpec]:
    if mode == "none" or max_tools <= 0 or len(tools) <= max_tools:
        return tools
    scored = [(score_tool_relevance(user_prompt, t), t) for t in tools]
    scored.sort(key=lambda x: x[0], reverse=True)
    # Keep at least those with positive scores; if all zero, fall back to first max_tools
    positives = [t for s, t in scored if s > 0]
    if positives:
        return positives[:max_tools]
    return [t for _, t in scored[:max_tools]]

def build_tool_briefing(tools: List[ToolSpec], max_lines: int = 12) -> str:
    # Very short, model-friendly overview to reduce confusion.
    lines = []
    for i, t in enumerate(tools[:max_lines], 1):
        required = []
        try:
            req = t.parameters.get("required", [])
            required = req if isinstance(req, list) else []
        except Exception:
            pass
        req_preview = ", ".join(required[:3])
        cat = f"[{t.category}] " if t.category else ""
        line = f"{i}. {cat}{t.name}: {t.description}"
        if req_preview:
            line += f" (required: {req_preview})"
        lines.append(line)
    if not lines:
        return ""
    return (
        "TOOL BRIEFING (auto-generated)\n"
        "- Use only when needed. Fill parameters exactly per schema.\n"
        + "\n".join(lines) +
        "\nEND TOOL BRIEFING"
    )

# =========================
# 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 (filter + auto briefing + **kwargs passthrough)
# =========================

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,
    tool_filter_mode: str = "none",
    tool_filter_max: int = 32,
    auto_tool_brief: bool = True,
    max_brief_tools: int = 12,
) -> str:
    # 1) Tool shortlist (heuristic) to avoid sending hundreds of tools.
    selected_tools = filter_tools(user_prompt, tools, tool_filter_mode, tool_filter_max)

    # 2) Auto tool briefing text (short summary) to stabilize usage.
    system_prompt_final = system_prompt or provider.system_prompt_default or "You are a helpful assistant."
    if auto_tool_brief:
        brief = build_tool_briefing(selected_tools, max_lines=max_brief_tools)
        if brief:
            system_prompt_final = f"{system_prompt_final.strip()}\n\n{brief}"

    # 3) Messages
    messages = [
        {"role": "system", "content": system_prompt_final},
        {"role": "user", "content": user_prompt},
    ]

    # 4) Build payload for selected tools
    builder = select_tool_builder(provider.tool_json)
    tools_json = builder.build(selected_tools)

    # 5) Merge parameters
    params = dict(provider.default_parameters or {})
    if params_override:
        params = {**params, **params_override}

    # 6) Run
    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(selected_tools)
                return await runner.run(client, provider.model, messages, tools_json, params, user)
            else:
                runner = CompletionsRunner(selected_tools)
                return await runner.run(client, provider.model, messages, tools_json, params, user)

# =========================
# Nodes
# =========================

# -- Provider

@dataclass
class AdvancedOptions:
    tools: List[ToolSpec]
    custom_provider: Optional[ProviderProfile]
    system_prompt: Optional[str]
    parameters: Dict[str, Any]  # forwarded as **kwargs
    # scaling options:
    tool_filter_mode: str
    tool_filter_max: int
    auto_tool_brief: bool
    max_brief_tools: int

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\ntemperature: 0.5\n"}),
                "system_prompt_default": ("STRING", {"multiline": True, "default": ""}),
            }
        }

    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 = "", system_prompt_default: 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,
            system_prompt_default=system_prompt_default if system_prompt_default.strip() else None,
        )
        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."""

    @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}),
                "category": ("STRING", {"default": "filesystem"}),
                "tags": ("STRING", {"default": "list,files"}),
                "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, category="filesystem", tags="list,files", 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, category=category or None, tags=[t.strip() for t in tags.split(",")] if tags else None)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

class FileReadToolNode:
    """Create a 'read text file' tool."""

    @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"}),
                "category": ("STRING", {"default": "filesystem"}),
                "tags": ("STRING", {"default": "read,file,text"}),
                "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", category="filesystem", tags="read,file,text", 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, category=category or None, tags=[t.strip() for t in tags.split(",")] if tags else None)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

class FileWriteToolNode:
    """Create a 'write text file' tool."""

    @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}),
                "category": ("STRING", {"default": "filesystem"}),
                "tags": ("STRING", {"default": "write,file,text"}),
                "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, category="filesystem", tags="write,file,text", 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, category=category or None, tags=[t.strip() for t in tags.split(",")] if tags else None)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

# -- Generic: wrap any python module:function as a tool

class PythonCallableToolNode:
    """
    Wrap a Python callable (module:function) as a tool.
    The callable must accept a single dict argument and return a JSON-serializable dict.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "name": ("STRING", {"default": "my_python_tool"}),
                "description": ("STRING", {"default": "Generic Python callable tool."}),
                "module_function": ("STRING", {"default": "mypackage.mymodule:myfunc"}),  # "pkg.mod:fn"
                "parameters_json": ("STRING", {
                    "multiline": True,
                    "default": json.dumps({
                        "type": "object",
                        "properties": {"x": {"type": "number"}, "y": {"type": "number"}},
                        "required": ["x", "y"],
                        "additionalProperties": False
                    }, ensure_ascii=False, indent=2)
                }),
            },
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
                "category": ("STRING", {"default": "generic"}),
                "tags": ("STRING", {"default": "python,custom"}),
                "preset_args": ("STRING", {"multiline": True, "default": ""}),  # loose "k: v" merged into args
                "callback": ("OPENAI_CALLBACK",),
            }
        }

    RETURN_TYPES = ("OPENAI_TOOLS",)
    RETURN_NAMES = ("tools",)
    FUNCTION = "append_tool"
    CATEGORY = "OpenAI/Tools"

    def append_tool(self, name, description, module_function, parameters_json, tools_in=None, category="generic", tags="python,custom", preset_args="", callback=None):
        try:
            schema = json.loads(parameters_json)
        except Exception:
            schema = {"type": "object", "properties": {}, "additionalProperties": True}

        # Resolve module:function
        if ":" not in module_function:
            raise ValueError("module_function must be in 'package.module:function' format")
        mod_name, fn_name = module_function.split(":", 1)
        mod = importlib.import_module(mod_name)
        if not hasattr(mod, fn_name):
            raise ValueError(f"Function '{fn_name}' not found in module '{mod_name}'")
        target_fn = getattr(mod, fn_name)
        preset = parse_loose_params(preset_args or "")

        def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
            try:
                merged = {**preset, **(args or {})}
                result = target_fn(merged)
                if callback: callback.emit("tool_done", {"tool": name})
                if isinstance(result, dict):
                    return result
                # Normalize to dict
                return {"result": result}
            except Exception as e:
                if callback: callback.emit("tool_error", {"tool": name, "reason": "exception", "error": repr(e)})
                return {"error": repr(e)}

        spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl, category=category or None, tags=[t.strip() for t in tags.split(",")] if tags else None)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

# -- Advanced options aggregator

class OpenAIAdvancedOptionsNode:
    """
    Aggregate tools, custom provider, system prompt, and parameters (verbatim **kwargs),
    plus scaling controls for large tool sets.
    """

    @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"
                }),
                "tool_filter_mode": (["none", "heuristic"], {"default": "heuristic"}),
                "tool_filter_max": ("INT", {"default": 32, "min": 1, "max": 512}),
                "auto_tool_brief": ("BOOLEAN", {"default": True}),
                "max_brief_tools": ("INT", {"default": 12, "min": 0, "max": 100}),
            }
        }

    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 = "",
        tool_filter_mode: str = "heuristic",
        tool_filter_max: int = 32,
        auto_tool_brief: bool = True,
        max_brief_tools: int = 12,
    ):
        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 "")
        adv = AdvancedOptions(
            tools=tlist,
            custom_provider=provider,
            system_prompt=sys_prompt,
            parameters=params_dict,
            tool_filter_mode=tool_filter_mode,
            tool_filter_max=tool_filter_max,
            auto_tool_brief=auto_tool_brief,
            max_brief_tools=max_brief_tools,
        )
        return (adv,)

# -- Chat

class OpenAIChatNode:
    """
    Async chat node with scalable tool handling:
      - Provider chosen by name from config.toml (auto-refreshed), overrideable via AdvancedOptions.custom_provider
      - Parameters: provider.default_parameters merged with AdvancedOptions.parameters (right wins)
      - Tool filtering + auto-briefing to keep prompts lean and tool usage stable
    """

    @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": "예시: 문서에서 핵심을 추출하고 요약해줘."}),
                "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 = provider.system_prompt_default or "You are a helpful assistant."
        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

        # Parameters merge
        params_override: Dict[str, Any] = {}
        if advanced and isinstance(advanced, AdvancedOptions) and advanced.parameters:
            params_override = advanced.parameters

        # Scaling controls
        tool_filter_mode = "none"
        tool_filter_max = 32
        auto_tool_brief = True
        max_brief_tools = 12
        if advanced and isinstance(advanced, AdvancedOptions):
            tool_filter_mode = advanced.tool_filter_mode
            tool_filter_max = advanced.tool_filter_max
            auto_tool_brief = advanced.auto_tool_brief
            max_brief_tools = advanced.max_brief_tools

        # 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,
                tool_filter_mode=tool_filter_mode,
                tool_filter_max=tool_filter_max,
                auto_tool_brief=auto_tool_brief,
                max_brief_tools=max_brief_tools,
            )
            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,
    "PythonCallableToolNode": PythonCallableToolNode,
}

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",
    "PythonCallableToolNode": "Tool: Python Callable",
}

요약

  • 도메인에 묶이지 않게 툴을 일반화했고, 노드마다 어떤 툴이든 만들 수 있어.

  • 툴이 많아져도 자동 선별과 짧은 브리핑으로 모델 혼란과 프롬프트 길이를 제어.

  • 시스템 프롬프트는 “툴-불가지론”으로 짧게, 작업별로 AdvancedOptions.system_prompt로 얹는 2단 구성.

  • 나중에 벤더나 모델이 늘어나도, 툴 정의/메타데이터와 선별·브리핑 로직만 유지하면 확장 가능해.

질문. 툴에 대한 내용을 시스템 프롬프트에 넣어야 하나? (그래야 성능이 더 좋나?)

짧은 결론: “툴 정보 전부”를 시스템 프롬프트에 넣을 필요는 없고, 오히려 성능·비용에 악영향이 납니다. 대신 다음 원칙이 가장 안정적입니다.

  1. 시스템 프롬프트에는 “툴 사용 정책”만 짧게 넣기
  • 언제 툴을 써야 하는지, 출력 형식, 보안 제약, 오류 처리 같은 규칙을 3–6문장으로 명시합니다.

  • 예: 파일 내용이 필요하면 반드시 read_text_file을 호출, base_dir 밖 접근 금지, 추측 금지, 한국어로 간결히 등.

  1. 구체적인 툴 스키마/설명은 tools 파라미터로만 전달
  • 모델은 tools에 있는 JSON 스키마를 근거로 파라미터를 채웁니다. 같은 내용을 시스템 프롬프트에 중복하면 토큰만 낭비됩니다.
  1. 툴이 많을 때는 “짧은 브리핑”만 추가
  • 매 요청마다 관련 Top-K 툴만 선별해, 이름·한줄설명·필수 파라미터만 1~2줄씩 요약한 브리핑을 시스템 프롬프트 뒤에 덧붙이면 선택 정확도가 올라갑니다.

  • 길이 가이드: 6~12개 이하, 줄당 1문장, 총 10~15줄 이내.

  1. 언제 브리핑도 생략하나
  • 툴 수가 적고 이름·설명이 명확할 때.

  • 컨텍스트가 촉박하거나 응답 지연을 최소화해야 할 때.

  • 이미 이전 턴들에서 모델이 올바르게 툴을 사용 중일 때.

추천 기본 셋업(지금 노드 설계 기준)

  • 시스템 프롬프트 기본값: 아주 짧은 정책 4줄 정도.

  • tools 파라미터: 모든 툴의 JSON 스키마.

  • 자동 브리핑: Heuristic Top-K로 6~12개만, 한 줄 요약.

  • 겹치는 정보(스키마 본문)를 시스템 프롬프트에 복붙하지 않기.

샘플 시스템 프롬프트(짧은 정책 템플릿)

  • 필요 시에만 툴을 사용한다. 파일/네트워크 등 내용이 필요하면 반드시 해당 툴을 호출하고 추측하지 않는다.

  • 툴 파라미터는 스키마에 맞게 정확히 채운다. base_dir 밖 경로는 요청하지 않는다.

  • 오류가 나면 원인과 다음 시도를 간결히 제안한다(예: 경로 수정·인코딩 변경 등).

  • 출력은 한국어로 간결하게, 중간 사고과정은 노출하지 않는다.

샘플 브리핑(자동 생성 예)

  1. list_files_in_folder: 폴더의 파일명을 나열 (required: folder_path)

  2. read_text_file: 텍스트 파일을 읽어 반환 (required: file_path)

  3. write_text_file: 텍스트를 파일로 저장 (required: file_path, content)

실무 팁

  • 브리핑의 효과는 “툴 선택 정확도↑, 재시도↓”에서 가장 큽니다. 다만 브리핑이 길어지면 반대로 혼란/비용이 늘 수 있으니 Top-K로 제한하세요.

  • 모델을 바꿔도 이 원칙은 유효합니다. 시스템 프롬프트에는 규칙, tools에는 스키마, 브리핑은 짧게 필요한 것만.

  • 여러분이 만든 자동 필터링/브리핑 옵션을 기본 ON, 길이는 보수적으로 두고(예: 8~12개) 상황에 맞게만 확장하세요.

그럼 유저가 안 써도 기본적으로 다 넣는게 좋단 말인가? 그냥 api 에만 툴 선언하고 쓰면 안돼?

짧게 답하면: “API에 툴만 등록해도 보통 잘 동작한다.” 다만 기본적인 “툴 사용 정책” 3–5줄을 시스템 프롬프트에 항상 얹어두면, 모델/툴이 많아질수록 선택 정확도와 안정성이 올라가는 편이야. 툴 스키마 자체를 시스템 프롬프트에 복붙하는 건 비효율이니 피하고.

권장 기본 전략

  1. 항상 API의 tools 파라미터로 스키마를 전달한다. 여기서가 진짜 근거다.

  2. 시스템 프롬프트에는 정책만 아주 짧게 넣는다. 예시:

    • 필요한 경우에만 툴을 호출한다.

    • 파일/네트워크 등 내용이 필요하면 추측하지 말고 해당 툴을 호출한다.

    • 툴 파라미터는 스키마에 맞춘다.

    • 보안 제약(예: base_dir 밖 접근 금지)을 지킨다.

    • 한국어로 간결히 답한다.

  3. 툴이 많을 때만 짧은 브리핑을 자동으로 추가한다. Top-K 이름+한줄 설명+필수 파라미터 정도로 6–12줄. 스키마 중복 금지.

  4. 툴이 5개 이하면 브리핑 생략해도 무방하다.

언제 “API에만 툴 선언”으로 충분하나

  • 툴 수가 적고 이름·설명이 명확할 때

  • 반복 사용 중이라 모델이 이미 올바르게 호출하는 패턴이 잡혔을 때

  • 지연시간·토큰 비용을 최소화해야 할 때

언제 “짧은 정책 + 브리핑”이 체감 효과가 크나

  • 툴이 10개 이상으로 늘고, 일부가 이름/설명이 비슷할 때

  • 외부 모델(벤더별)로 바꾸면서 도구 호출 성향이 달라질 때

  • 사용자가 모호한 요청을 자주 던질 때

지금 네 노드 설계에 바로 적용하는 디폴트

  • Provider의 system_prompt_default에 3–5줄 정책을 넣는다.

  • AdvancedOptions 기본값을 다음처럼 두는 걸 추천.

    • tool_filter_mode: heuristic

    • tool_filter_max: 32

    • auto_tool_brief: True

    • max_brief_tools: 8~12

  • 툴이 5개 이하이면 auto_tool_brief를 False로 꺼도 좋다.

운영 중 점검 지표

  • 잘못된 툴 호출율(없는 툴명, 누락된 required 파라미터)

  • 평균 라운드 수(재시도 횟수)

  • 토큰/지연시간

  • 작업 성공률(휴먼 평가)

정리
그냥 tools만 API로 넘겨도 “대체로” 된다. 하지만 기본 정책 몇 줄은 싸게 가져가는 품질 보험이고, 툴이 많아지는 순간 짧은 브리핑이 유효하다. 결론적으로 “툴 스키마는 API에, 정책은 시스템 프롬프트에, 브리핑은 필요할 때만”이 가장 비용 대비 성과가 좋은 셋업이다.

dataclass와 pydandic model 차이

다음은 Python 표준 dataclass와 Pydantic의 BaseModel(v2 기준)을 비교·정리한 거야. 실무에서 어느 걸 쓰면 좋은지까지 짚었어.

핵심 차이 요약

항목 dataclass (표준 라이브러리) Pydantic BaseModel (외부 패키지)
목적 경량 데이터 컨테이너 입력 검증·변환 + 직렬화·스키마
타입 체크 정적 힌트만, 런타임 검증 없음 런타임 검증·변환(엄격/관대한 모드)
기본값 field(default, default_factory) Field(default, default_factory, alias, description…)
불변/슬롯 frozen=True, slots=True 지원 ConfigDict(frozen=True) 등 설정
직렬화 asdict, astuple model_dump, model_dump_json, 별칭/필드제외 등 풍부
스키마 없음 JSON Schema 자동 생성
중첩 모델 가능(검증 없음) 깊은 중첩 검증·변환
밸리데이터 직접 코드 작성 @field_validator, @model_validator
성능/오버헤드 매우 적음 검증 비용 존재(대신 안전성↑)
설정/환경 없음 설정용 모델(pydantic-settings)과 궁합

사용 기준 한 줄 가이드

  • “신뢰된 내부 데이터, 간단한 DTO” → dataclass.

  • “외부 입력(요청 바디/환경변수/파일/DB) 검증 필요” → Pydantic.

코드 비교

간단 예시: 사용자 입력을 받아 나이와 이메일을 담는다.

dataclass: 타입 힌트는 있지만 잘못된 값이 들어와도 자동 검증 안 함.

from dataclasses import dataclass, field, asdict

@dataclass(slots=True)
class User:
    name: str
    age: int
    email: str = field(default="")

u = User(name="Alice", age="20", email="alice@example.com")  # 런타임 검증 없음. age는 str 들어와도 그대로.
print(asdict(u))  # {'name': 'Alice', 'age': '20', 'email': 'alice@example.com'}

Pydantic: 자동 변환·검증, 제약 조건·밸리데이터를 쉽게 부착.

from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator

class User(BaseModel):
    model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
    name: str
    age: int = Field(ge=0, le=150)
    email: EmailStr = ""

    @field_validator("name")
    @classmethod
    def non_empty(cls, v: str) -> str:
        if not v:
            raise ValueError("name must not be empty")
        return v

u = User(name="  Alice  ", age="20", email="alice@example.com")  # "20" → 20으로 변환
print(u.model_dump())  # {'name': 'Alice', 'age': 20, 'email': 'alice@example.com'}
u.age = 999  # validate_assignment로 즉시 오류 발생

직렬화·스키마·별칭

  • dataclass: 기본 asdict만. 필드 숨김/별칭, JSON 스키마는 직접 구현해야 함.

  • Pydantic:

    • model_dump(exclude=…, by_alias=True) 같은 고급 옵션.

    • model_json_schema()로 OpenAPI/JSON Schema 생성.

    • 필드 별칭, 설명, 예시 등 문서화 메타데이터 손쉽게 부여.

from pydantic import BaseModel, Field

class Item(BaseModel):
    id: int = Field(alias="item_id", description="Public ID")

item = Item(item_id=1)
print(item.model_dump(by_alias=True))  # {'item_id': 1}
print(item.model_json_schema()["properties"]["item_id"]["description"])

불변성/슬롯/성능

  • dataclass는 frozen=True, slots=True로 매우 가볍게 동작.

  • Pydantic도 ConfigDict(frozen=True) 가능하지만, 검증/변환 코스트가 있어 순수한 속도는 dataclass가 유리.

혼합 사용 팁

  • Pydantic v2는 pydantic.dataclasses.dataclass 데코레이터도 제공해서 dataclass에 검증을 입힐 수 있어.
    “대부분 dataclass지만 몇 필드만 검증 필요” 같은 케이스에 유용.
from pydantic.dataclasses import dataclass
from pydantic import ConfigDict

@dataclass(config=ConfigDict(validate_assignment=True))
class Config:
    port: int
    host: str = "127.0.0.1"

c = Config(port="8000")  # "8000" → 8000으로 변환

결정 트리

  1. 외부 입력을 받아야 하나?
  • 예: API 바디, 폼, ENV, 파일, 메시지 큐 → Pydantic
  1. 내부에서 생성·소비되는 단순 DTO인가?
  • 성능·단순성이 중요 → dataclass
  1. 문서화/스키마/별칭/엄격한 제약이 필요한가?
  • 필요 → Pydantic
  1. 둘 다 쓰고 싶다?
  • 기본은 dataclass, 경계(입출력)에서만 Pydantic으로 검증 후 내부로는 dataclass 변환 사용도 좋다.

정리하면, dataclass는 “가볍고 빠른 컨테이너”, Pydantic은 “신뢰할 수 있는 입력계 검증기 + 직렬화 도구”야. 프로젝트에서 외부 데이터와 맞닿는 부분만 Pydantic으로 감싸고, 내부 도메인 모델은 dataclass로 두면 유지보수와 성능 균형이 잘 맞는 경우가 많다.

아니 근데 ToolSpec에 category랑 tag는 왜 넣어놨어? 이게 LLM이 전달이 되는 것도 아닌것 같은데

짧게 말하면, 맞아요. categorytags는 기본 tools JSON에는 안 들어가서 LLM에 “직접” 전달되진 않아요. 넣어둔 이유는 런타임 오케스트레이션(사전 선택·정책·로깅)과 “간접 전달(브리핑)”에 쓰려고 했기 때문이에요.

왜 필요하냐

  1. 대규모 툴 셋 선별
  • 툴이 10~100개로 늘면 전부 모델에 던지기보단, 사용자 프롬프트와의 간단한 유사도 점수로 Top-K만 고르는 게 안정적이에요. 이때 툴 이름·설명만 쓰면 구분이 부족해서, category/tags까지 가중치에 포함하면 선별 정확도가 올라가요.

  • 코드에서 score_tool_relevance()가 name/description/parameters 외에 category/tags도 함께 사용합니다.

  1. 정책·권한·보안 게이팅
  • 운영/프로덕션에서 “filesystem:write” 카테고리는 막고 “filesystem:read”만 허용 같은 정책을 쉽게 걸 수 있어요. 툴 메타가 있으면 allowlist/denylist가 간단해집니다.

  • 예: allowed_categories = {"filesystem:read","analytics"}로 필터링.

  1. 브리핑으로 간접 전달
  • 시스템 프롬프트에 “툴 전체 스키마”를 복붙하지는 않지만, 자동 툴 브리핑을 만들 때 [filesystem] list_files_in_folder: …처럼 category를 표시해서 모델이 선택을 덜 헷갈리게 합니다. 즉, category/tags는 “브리핑 텍스트”에 녹아 LLM에 간접적으로 전달됩니다.
  1. 운영 가시성·로깅·A/B
  • 호출 로그에서 “어떤 카테고리의 툴을 얼마나 선택했는지”를 바로 집계할 수 있어요. 카테고리 기반으로 성공/실패율, 재시도율, 토큰 비용 등을 비교하기 좋습니다.

원한다면 LLM에 직접 태그를 “보이게” 만드는 방법

  • 가장 간단한 방법은 빌드 타이밍에 description에 합쳐 넣는 거예요. 예: description = f"[{category}] {description} (tags: {', '.join(tags)})". 이렇게 하면 tools JSON 자체에 반영되어 모델이 그대로 봅니다.

  • 혹은 자동 브리핑에 태그까지 노출하도록 한 줄을 바꾸면 됩니다. 지금 코드는 [category]만 넣지만 tags도 덧붙이도록 해도 돼요.

원치 않으면 지워도 되나

  • 네. 툴이 몇 개 안 되고, 정책/선별/로깅 요구가 없으면 category/tags를 빼도 됩니다. 그 대신 나중에 커질 때를 대비해 metadata: dict 하나만 남겨 두는 것도 방법입니다.

작게 손보는 예시

  • 브리핑에 태그까지 보여 주기:
def build_tool_briefing(tools, max_lines=12):
    lines = []
    for i, t in enumerate(tools[:max_lines], 1):
        req = (t.parameters.get("required", []) if isinstance(t.parameters, dict) else []) or []
        cat = f"[{t.category}] " if t.category else ""
        tagline = f" tags={','.join(t.tags)}" if t.tags else ""
        line = f"{i}. {cat}{t.name}: {t.description}{tagline}"
        if req:
            line += f" (required: {', '.join(req[:3])})"
        lines.append(line)
    return "TOOL BRIEFING\n" + "\n".join(lines) + "\nEND TOOL BRIEFING" if lines else ""
  • 정책 게이팅(예: 쓰기 금지):
def gate_tools(tools, deny_categories={"filesystem:write"}):
    return [t for t in tools if (t.category or "") not in deny_categories]

run_with_provider() 들어가기 전에 tools = gate_tools(tools) 한 줄이면 끝.

정리

  • category/tags는 LLM에 “직접” 가는 필드는 아니고, 툴 선별·정책·로깅·브리핑을 위한 메타데이터입니다.

  • 브리핑을 쓰면 간접적으로 모델 판단에 도움을 주고, 필요하면 description에 합쳐 “직접” 보이게도 만들 수 있어요.

  • 요구가 단순하면 빼도 무방하지만, 툴이 늘어날수록 이 메타가 운영 품질과 유지보수에 큰 도움이 됩니다.

넣었다쳐도 그걸 입력으로 받는건 좀 많이 이상하지 않아?

맞아, 툴을 만드는 노드에서 category/tags를 “사용자 입력”으로 받는 건 UX상 어색해. 그 필드는 LLM에 직접 전달되지도 않고, 주로 오케스트레이션(선별·정책·로그) 용 메타데이터라서 내부적으로 자동 추론하거나 필요할 때만 별도 주석 노드로 덧씌우는 게 깔끔해.

정리

  1. 툴 노드에서는 category/tags 입력을 아예 없앤다.

  2. 노드 내부에서 도구명·설명·스키마를 기반으로 가벼운 휴리스틱으로 자동 추론한다.
    예: read_*/list_*filesystem:read, write_*/save_*filesystem:write 등.

  3. 정말 필요할 때만 “주석(annotate) 노드”로 선택 툴에 메타데이터를 덧입힌다.
    이렇게 하면 기본 흐름은 단순하고, 규모가 커질 때만 메타 관리가 개입된다.

아래는 그렇게 바꾸는 최소 변경 코드 조각이야. 전체 파일 갈아엎을 필요 없이 이 부분만 교체하면 돼.

코드: 메타 자동 추론 유틸

# --- put near ToolSpec definition ---

from typing import Tuple

def infer_tool_metadata(name: str, description: str, parameters: dict, default_category: str | None = None) -> Tuple[Optional[str], Optional[List[str]]]:
    nm = (name or "").lower()
    desc = (description or "").lower()
    cat = None

    # very light heuristics
    if nm.startswith(("read_", "list_", "stat_", "head_", "get_")):
        cat = "filesystem:read"
    elif nm.startswith(("write_", "save_", "append_", "put_", "delete_", "remove_")):
        cat = "filesystem:write"
    elif "http" in nm or "fetch" in nm or "download" in nm:
        cat = "network"
    if default_category:
        cat = default_category

    # tags: few words from description + required keys
    req = []
    if isinstance(parameters, dict):
        req = parameters.get("required", []) or []
    tokens = re.findall(r"[a-z0-9_]+", desc)
    tags = list(dict.fromkeys((tokens[:6] + list(req)[:4])))  # keep order, unique, short
    return cat, (tags or None)

코드: 브리핑은 있으면만 보여 주도록 유지

def build_tool_briefing(tools: List[ToolSpec], max_lines: int = 12) -> str:
    lines = []
    for i, t in enumerate(tools[:max_lines], 1):
        req = []
        if isinstance(t.parameters, dict):
            req = (t.parameters.get("required", []) or [])[:3]
        cat = f"[{t.category}] " if getattr(t, "category", None) else ""
        line = f"{i}. {cat}{t.name}: {t.description}"
        if req:
            line += f" (required: {', '.join(req)})"
        lines.append(line)
    return "TOOL BRIEFING\n" + "\n".join(lines) + "\nEND TOOL BRIEFING" if lines else ""

코드: 파일 툴 노드들에서 입력 항목에서 category/tags 제거, 내부 자동 추론 적용

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",),
            }
        }
    ...
    def append_tool(...):
        schema = { ... }  # 동일
        def _impl(args: Dict[str, Any]) -> Dict[str, Any]: ...
        cat, tags = infer_tool_metadata(name, description, schema, default_category="filesystem:read")
        spec = ToolSpec(name=name, description=description, parameters=sc hema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
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",),
            }
        }
    ...
    def append_tool(...):
        schema = { ... }
        def _impl(args: Dict[str, Any]) -> Dict[str, Any]: ...
        cat, tags = infer_tool_metadata(name, description, schema, default_category="filesystem:read")
        spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)
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",),
            }
        }
    ...
    def append_tool(...):
        schema = { ... }
        def _impl(args: Dict[str, Any]) -> Dict[str, Any]: ...
        cat, tags = infer_tool_metadata(name, description, schema, default_category="filesystem:write")
        spec = ToolSpec(name=name, description=description, parameters=schema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

선택 사항: 필요 시에만 메타데이터를 덧입히는 주석 노드

class OpenAIToolAnnotateNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "tools_in": ("OPENAI_TOOLS",),
                "name_regex": ("STRING", {"default": ".*"}),         # 대상 선택
            },
            "optional": {
                "category": ("STRING", {"default": ""}),
                "tags_csv": ("STRING", {"default": ""}),             # "foo,bar,baz"
            }
        }
    RETURN_TYPES = ("OPENAI_TOOLS",)
    RETURN_NAMES = ("tools",)
    FUNCTION = "annotate"
    CATEGORY = "OpenAI/Tools"

    def annotate(self, tools_in, name_regex, category="", tags_csv=""):
        import re
        if not isinstance(tools_in, list):
            return ([],)
        rx = re.compile(name_regex)
        tags = [t.strip() for t in tags_csv.split(",")] if tags_csv.strip() else None
        out = []
        for t in tools_in:
            if rx.fullmatch(t.name):
                if category.strip():
                    t.category = category.strip()
                if tags is not None:
                    t.tags = tags or None
            out.append(t)
        return (out,)

왜 이게 좋은가

  1. 기본 UX가 단순해진다. 툴 노드는 본질 입력만 받고, 메타는 자동으로 붙는다.

  2. 메타는 운영 기능(선별·정책·로그)에만 쓰이며 LLM에 강제 노출되지 않는다.

  3. 필요할 때만 주석 노드로 덧씌울 수 있어 확장이 쉽다.

  4. 메타가 없어도 기존 휴리스틱(이름·설명·스키마 기반 Top-K 선별)이 돌아가므로 안전한 디폴트다.

요약

  • 네 말이 맞다. category/tags를 “노드 입력”으로 받는 건 과도하다.

  • 코어 툴 노드에서는 입력에서 제거하고, 내부에서 자동 추론하자.

  • 정말 필요한 경우에만 별도 “Annotate” 노드로 주석을 덧입히는 하이브리드가 가장 깔끔하고 운영 친화적이다.

아니 name이랑 description도 그래. 그걸 왜 입력으로 받냐고

맞아. 일반 사용자에게 name/description을 매번 입력시키는 건 UX상 불필요하고, 실수도 유발해. 툴은 노드의 설정으로부터 자동으로 “함수 이름”과 “짧은 설명”을 만들고, 정말 필요할 때만 덮어쓰는 옵셔널 입력을 두는 게 가장 깔끔해.

아래처럼 바꾸면 돼.

  1. 원칙
  • LLM이 실제로 선택하는 건 툴의 function name이므로, 이름은 안정적·고유해야 한다.

  • 이름과 설명은 노드 입력에서 제거하고, 노드 설정값을 해시해 자동 생성한다.

  • 필요 시에만 optional override를 제공한다(없으면 전부 자동값 사용).

  • 이름은 짧고 스키마 중심, 설명은 한 줄 요약만. 상세는 tools JSON의 parameters가 담당.

  1. 자동 생성 규칙
  • 이름: {prefix}_{short_hash(config)} 형식, 소문자+숫자+밑줄만, 길이 제한.

  • 설명: 동작 1문장 + 중요 파라미터 힌트 1문장.

  • 해시는 base_dir·encoding·append 등 노드 설정을 json.dumps(sort_keys=True) 후 SHA1 8자.

  1. 코드 변경점만 제시
    아래 조각을 기존 파일에 추가/교체하면 된다. 다른 부분은 그대로 유지해도 된다.

헬퍼: 이름/설명 생성

import hashlib
import json
import re

def _slug_fn_name(prefix: str, payload: dict, max_len: int = 48) -> str:
    raw = json.dumps(payload or {}, sort_keys=True, ensure_ascii=False)
    h = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
    base = f"{prefix}_{h}".lower()
    base = re.sub(r"[^a-z0-9_]", "_", base)
    return base[:max_len]

def _auto_desc(base: str, hints: list[str] | None = None) -> str:
    parts = [base]
    if hints:
        parts.append(" ".join([h for h in hints if h]))
    return " ".join(parts).strip()

FileListToolNode: name/description 입력 제거, 자동 생성 + 선택적 override

class FileListToolNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {},
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
                "base_dir": ("STRING", {"default": ""}),
                "return_full_paths": ("BOOLEAN", {"default": False}),
                "callback": ("OPENAI_CALLBACK",),
                "function_name_override": ("STRING", {"default": ""}),
                "description_override": ("STRING", {"default": ""}),
            }
        }

    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, tools_in=None, base_dir="", return_full_paths=False, callback=None,
                    function_name_override: str = "", description_override: str = ""):
        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": fn_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": fn_name, "count": len(out)})
            return {"result": sorted(out)}

        auto_name_payload = {"base_dir": base_dir or "", "return_full_paths": bool(return_full_paths), "type": "list"}
        fn_name = function_name_override.strip() or _slug_fn_name("list_files", auto_name_payload)
        desc = description_override.strip() or _auto_desc(
            "List files in a folder.",
            [f"Returns {'absolute' if return_full_paths else 'basename'} paths.", "Required: folder_path."]
        )

        # 메타 자동 추론(선택): 읽기 카테고리
        cat, tags = "filesystem:read", ["list", "files", "folder"]

        spec = ToolSpec(name=fn_name, description=desc, parameters=schema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

FileReadToolNode: 자동 생성

class FileReadToolNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {},
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
                "base_dir": ("STRING", {"default": ""}),
                "encoding": ("STRING", {"default": "utf-8"}),
                "callback": ("OPENAI_CALLBACK",),
                "function_name_override": ("STRING", {"default": ""}),
                "description_override": ("STRING", {"default": ""}),
            }
        }

    RETURN_TYPES, RETURN_NAMES = ("OPENAI_TOOLS",), ("tools",)
    FUNCTION, CATEGORY = "append_tool", "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, tools_in=None, base_dir="", encoding="utf-8", callback=None,
                    function_name_override: str = "", description_override: str = ""):
        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": fn_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": fn_name, "size": len(text)})
                return {"result": text}
            except Exception as e:
                if callback: callback.emit("tool_error", {"tool": fn_name, "reason": "read_failed", "error": repr(e)})
                return {"result": ""}

        auto_name_payload = {"base_dir": base_dir or "", "encoding": encoding or "utf-8", "type": "read"}
        fn_name = function_name_override.strip() or _slug_fn_name("read_text", auto_name_payload)
        desc = description_override.strip() or _auto_desc(
            "Read a UTF-8 text file.",
            [f"Default encoding={encoding}.", "Required: file_path."]
        )

        cat, tags = "filesystem:read", ["read", "file", "text"]
        spec = ToolSpec(name=fn_name, description=desc, parameters=schema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

FileWriteToolNode: 자동 생성

class FileWriteToolNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {},
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
                "base_dir": ("STRING", {"default": ""}),
                "encoding": ("STRING", {"default": "utf-8"}),
                "append": ("BOOLEAN", {"default": False}),
                "callback": ("OPENAI_CALLBACK",),
                "function_name_override": ("STRING", {"default": ""}),
                "description_override": ("STRING", {"default": ""}),
            }
        }

    RETURN_TYPES, RETURN_NAMES = ("OPENAI_TOOLS",), ("tools",)
    FUNCTION, CATEGORY = "append_tool", "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, tools_in=None, base_dir="", encoding="utf-8", append=False, callback=None,
                    function_name_override: str = "", description_override: str = ""):
        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": fn_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": fn_name, "bytes": len(content)})
                return {"result": True}
            except Exception as e:
                if callback: callback.emit("tool_error", {"tool": fn_name, "reason": "write_failed", "error": repr(e)})
                return {"result": False}

        auto_name_payload = {"base_dir": base_dir or "", "encoding": encoding or "utf-8", "append": bool(append), "type": "write"}
        fn_name = function_name_override.strip() or _slug_fn_name("write_text", auto_name_payload)
        desc = description_override.strip() or _auto_desc(
            "Write text to a file.",
            [f"Default encoding={encoding}. Append={append}.", "Required: file_path, content."]
        )

        cat, tags = "filesystem:write", ["write", "file", "text"]
        spec = ToolSpec(name=fn_name, description=desc, parameters=schema, func=_impl, category=cat, tags=tags)
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

정리

  • 기본값: 사용자에게 name/description을 요구하지 않는다.

  • 노드 설정을 기반으로 안정적이고 고유한 함수명을 자동 생성한다.

  • 설명도 자동으로 한 줄 생성한다.

  • 아주 특별한 사유로 바꾸고 싶을 때만 override 입력을 쓴다(비워두면 무시).

  • 이렇게 하면 UX는 단순해지고, LLM 측에는 여전히 좋은 품질의 이름/설명이 전달된다.

지금 뭔가 노드랑 실제 tool 함수랑 헷갈리고 있는 것 같아.

좋은 지적이야. “노드(Node)”와 “툴 함수(모델이 호출하는 function)”가 섞여 보일 수 있어. 딱 잘라 구분하자.

  1. 개념 정리
  • 노드(Node): ComfyUI에서 유저가 배선하는 에디터 컴포넌트. 입력을 받아 “툴 정의”를 만들어 내는 팩토리일 뿐이야. 노드 이름/메서드명은 모델에 안 보임.

  • 모델 툴(Model tool): 모델에 전달되는 JSON 스펙. 핵심 키는 tool_name(모델이 호출할 문자열 식별자), description, parameters(JSON Schema). 이게 tools 파라미터로 API에 들어감.

  • 실행기(Executor): 실제로 OS에서 파일 읽기/쓰기 등을 수행하는 파이썬 callable. 모델이 툴을 호출하면, 런타임이 tool_name → executor 매핑을 찾아 실행해 줘.

  1. 데이터 흐름
  • 각 Tool 노드 → ModelTool 객체(툴 정의) + ExecRegistry 등록(tool_name → executor)

  • Chat 노드 → 선택된 ModelTool들을 JSON으로 변환(tools_json)하여 API 호출

  • 모델 응답(tool_calls) → tool_name과 arguments만 들어옴

  • 러너가 ExecRegistrytool_name 실행 → 결과를 function_call_output/tool 메시지로 재주입

  1. 헷갈리지 않게 필드명 바꾸기(핵심만)
  • 기존 ToolSpec(name, description, parameters, func)을 아래처럼 명확히 분리:

    • ModelTool.tool_name: 모델이 보는 이름(문자열)

    • ModelTool.tool_desc: 모델이 보는 설명

    • ModelTool.json_schema: 모델이 보는 파라미터 스키마

    • executor: 파이썬 콜러블(LLM은 못 봄)

  • ExecRegistry: dict[str, Callable]로 별도 보관

  1. 최소 코드 변경 예시
from dataclasses import dataclass
from typing import Callable, Dict, Any, List

@dataclass
class ModelTool:
    tool_name: str
    tool_desc: str
    json_schema: Dict[str, Any]

# Local registry that runtime uses to execute tool calls
class ExecRegistry:
    def __init__(self):
        self._map: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}

    def register(self, tool_name: str, executor: Callable[[Dict[str, Any]], Dict[str, Any]]):
        if tool_name in self._map:
            raise ValueError(f"Duplicate tool_name: {tool_name}")
        self._map[tool_name] = executor

    def run(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
        fn = self._map.get(tool_name)
        if not fn:
            return {"error": f"Unknown tool: {tool_name}"}
        try:
            return fn(args)
        except Exception as e:
            return {"error": repr(e)}

# Tool JSON builders (what the model actually sees)
def build_tools_json_flat(tools: List[ModelTool]) -> List[Dict[str, Any]]:
    return [{"type": "function", "name": t.tool_name, "description": t.tool_desc, "parameters": t.json_schema} for t in tools]

def build_tools_json_nested(tools: List[ModelTool]) -> List[Dict[str, Any]]:
    return [{"type": "function", "function": {"name": t.tool_name, "description": t.tool_desc, "parameters": t.json_schema}} for t in tools]

툴 노드가 하는 일은 “ModelTool + executor”를 만드는 것뿐이야. 예를 들어 FileReadToolNode는 자동 생성된 tool_name/read_text_xxxxx와 짧은 tool_desc, json_schema를 만든 다음, 파일을 읽는 executor를 ExecRegistry에 등록해 준다. Chat 노드는 ModelTool들만 모아 tools_json을 만들고 API에 넣는다. 모델이 tool_name으로 호출하면 러너가 ExecRegistry.run(tool_name, args)로 실행한다.

  1. 왜 이렇게 나누면 좋은가
  • 노드 이름/설정과 모델 툴 이름을 혼동하지 않게 된다.

  • LLM에 보이는 것(ModelTool)과 로컬 실행기(Executor) 사이 경계가 선명해져 디버깅이 쉽다.

  • 동일 툴 JSON을 여러 프로바이더에 쓰되, 실행기는 환경별로 바꾸는 것도 간단하다.

  • 나중에 번들형 노드(여러 ModelTool 생성)나 주석 노드(메타 덧입히기)도 자연스럽게 얹을 수 있다.

정리하면, 노드는 “툴 정의의 생산자”, 모델에 보이는 건 ModelTool의 JSON만, 실행은 ExecRegistry가 담당. 이렇게 용어와 타입을 쪼개면 지금 느낀 혼동이 사라질 거야.

응 ToolSpec에는 name, description, parameters, func만 저장해. Optional: OPENAI_TOOLS required: 기타 사용자가 툴 노드 선언할 때 선언해줄만한 정보들 - base_dir 등 파일 append 유무 같은건 node 인풋이 아니라 사용할 때 LLM이 결정해야하는것에 가까우니 그건 schema(parameters)에 들어가는게 더 맞는 것 같은데? 잘 생각해서 Tool Node들만 잘 만들어줘

좋아. ToolSpec에는 name, description, parameters, func 네 가지만 두고, 각 Tool 노드는 사용자가 선언할 만한 환경 정보만(required) 받도록 단순화했다. append, encoding 같은 실행 옵션은 전부 툴의 parameters(JSON Schema)에 넣어 LLM이 호출 시 결정하도록 했다. 아래 3개 노드만 붙이면 된다.

# tool_nodes.py
# Minimal, focused Tool nodes for ComfyUI
# - ToolSpec: name, description, parameters, func
# - FileListToolNode: 폴더 내 파일 목록
# - FileReadToolNode: 텍스트 파일 읽기
# - FileWriteToolNode: 텍스트 파일 쓰기 (append 여부 등은 호출 시 결정)
#
# Note:
# - 각 노드는 required로 base_dir만 입력받고, tools_in은 선택(optional).
# - 스키마(parameters)에 실행 옵션을 정의하고, LLM이 호출 시 결정한다.
# - 안전을 위해 base_dir 밖 접근은 차단(_safe_join).

from __future__ import annotations

import os
import glob
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

# -----------------------
# Core spec
# -----------------------

@dataclass
class ToolSpec:
    name: str
    description: str
    parameters: Dict[str, Any]
    func: Any  # Callable[[Dict[str, Any]], Dict[str, Any]]

# -----------------------
# Common helpers
# -----------------------

def _safe_join(base_dir: str, path: str) -> Optional[str]:
    """Join and assert the target stays under base_dir. Returns None if invalid."""
    base = os.path.abspath(base_dir or "")
    target = os.path.abspath(os.path.join(base, path or ""))
    try:
        common = os.path.commonpath([base, target])
    except Exception:
        return None
    return target if (base and common == base) else None

def _normalize_bool(v: Any, default: bool = False) -> bool:
    if isinstance(v, bool):
        return v
    if isinstance(v, (int, float)):
        return bool(v)
    if isinstance(v, str):
        t = v.strip().lower()
        if t in {"1","true","yes","y","t"}:
            return True
        if t in {"0","false","no","n","f"}:
            return False
    return default

# -----------------------
# Tool Nodes
# -----------------------

class FileListToolNode:
    """
    Build a 'list files in folder' tool.
    Required: base_dir (sandbox root)
    Optional: tools_in (existing tool list to append)
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "base_dir": ("STRING", {"default": "./", "multiline": False}),
            },
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
            }
        }

    RETURN_TYPES = ("OPENAI_TOOLS",)
    RETURN_NAMES = ("tools",)
    FUNCTION = "append_tool"
    CATEGORY = "OpenAI/Tools"

    def append_tool(self, base_dir: str, tools_in=None):
        name = "list_files_in_folder"
        description = "List files in a folder under the configured base_dir. Does not traverse outside base_dir."
        parameters = {
            "type": "object",
            "properties": {
                "folder_path": {
                    "type": "string",
                    "description": "Relative path from base_dir to list."
                },
                "pattern": {
                    "type": "string",
                    "description": "Optional glob pattern (e.g., '*.txt'). If empty, lists all files."
                },
                "absolute": {
                    "type": "boolean",
                    "description": "If true, return absolute paths; otherwise return basenames."
                }
            },
            "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 = _normalize_bool(args.get("absolute"), False)

            resolved = _safe_join(base_dir, folder)
            if not resolved or not os.path.isdir(resolved):
                return {"error": "invalid_folder", "result": []}

            target = os.path.join(resolved, pattern) if pattern else os.path.join(resolved, "*")
            files = [p for p in glob.glob(target) if os.path.isfile(p)]
            items = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
            return {"result": sorted(items)}

        spec = ToolSpec(name=name, description=description, parameters=parameters, func=_impl)
        tool_list: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
        return (tool_list + [spec],)

class FileReadToolNode:
    """
    Build a 'read text file' tool.
    Required: base_dir
    Optional: tools_in
    Execution options (encoding 등)는 parameters에 정의되어 LLM이 호출 시 결정.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "base_dir": ("STRING", {"default": "./", "multiline": False}),
            },
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
            }
        }

    RETURN_TYPES = ("OPENAI_TOOLS",)
    RETURN_NAMES = ("tools",)
    FUNCTION = "append_tool"
    CATEGORY = "OpenAI/Tools"

    def append_tool(self, base_dir: str, tools_in=None):
        name = "read_text_file"
        description = "Read a UTF-8 (or specified encoding) text file under base_dir. Blocks path traversal."
        parameters = {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Relative path from base_dir to the file."
                },
                "encoding": {
                    "type": "string",
                    "description": "Text encoding (default 'utf-8')."
                },
                "max_bytes": {
                    "type": "integer",
                    "description": "Optional safety limit in bytes; if set and file is larger, it will be truncated."
                }
            },
            "required": ["file_path"],
            "additionalProperties": False
        }

        def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
            file_path = str(args.get("file_path", "") or "")
            encoding = str(args.get("encoding", "utf-8") or "utf-8")
            max_bytes = args.get("max_bytes", None)
            try:
                max_bytes = int(max_bytes) if max_bytes is not None else None
            except Exception:
                max_bytes = None

            resolved = _safe_join(base_dir, file_path)
            if not resolved or not os.path.isfile(resolved):
                return {"error": "invalid_file", "result": ""}

            try:
                if max_bytes is not None and max_bytes > 0:
                    # Read with size cap
                    with open(resolved, "rb") as fb:
                        data = fb.read(max_bytes)
                    text = data.decode(encoding, errors="replace")
                else:
                    with open(resolved, "r", encoding=encoding, errors="replace") as f:
                        text = f.read()
                return {"result": text}
            except Exception as e:
                return {"error": repr(e), "result": ""}

        spec = ToolSpec(name=name, description=description, parameters=parameters, func=_impl)
        tool_list: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
        return (tool_list + [spec],)

class FileWriteToolNode:
    """
    Build a 'write text file' tool.
    Required: base_dir
    Optional: tools_in
    append/encoding 등은 parameters로 받고 LLM이 호출 시 결정.
    """

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "base_dir": ("STRING", {"default": "./", "multiline": False}),
            },
            "optional": {
                "tools_in": ("OPENAI_TOOLS",),
            }
        }

    RETURN_TYPES = ("OPENAI_TOOLS",)
    RETURN_NAMES = ("tools",)
    FUNCTION = "append_tool"
    CATEGORY = "OpenAI/Tools"

    def append_tool(self, base_dir: str, tools_in=None):
        name = "write_text_file"
        description = "Write text content to a file under base_dir. Creates parent dirs if needed. Blocks path traversal."
        parameters = {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Relative path from base_dir to the file to write."
                },
                "content": {
                    "type": "string",
                    "description": "Text content to write."
                },
                "encoding": {
                    "type": "string",
                    "description": "Text encoding for writing (default 'utf-8')."
                },
                "append": {
                    "type": "boolean",
                    "description": "If true, append to the file; otherwise overwrite."
                }
            },
            "required": ["file_path", "content"],
            "additionalProperties": False
        }

        def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
            file_path = str(args.get("file_path", "") or "")
            content = str(args.get("content", "") or "")
            encoding = str(args.get("encoding", "utf-8") or "utf-8")
            do_append = _normalize_bool(args.get("append"), False)

            resolved = _safe_join(base_dir, file_path)
            if not resolved:
                return {"error": "invalid_path", "result": False}

            try:
                os.makedirs(os.path.dirname(resolved), exist_ok=True)
                mode = "a" if do_append else "w"
                with open(resolved, mode, encoding=encoding, errors="replace") as f:
                    f.write(content)
                return {"result": True}
            except Exception as e:
                return {"error": repr(e), "result": False}

        spec = ToolSpec(name=name, description=description, parameters=parameters, func=_impl)
        tool_list: List[ToolSpec] = tools_in if isinstance(tools_in, list) else []
        return (tool_list + [spec],)

# -----------------------
# ComfyUI export
# -----------------------

NODE_CLASS_MAPPINGS = {
    "FileListToolNode": FileListToolNode,
    "FileReadToolNode": FileReadToolNode,
    "FileWriteToolNode": FileWriteToolNode,
}

NODE_DISPLAY_NAME_MAPPINGS = {
    "FileListToolNode": "Tool: List Files (base_dir)",
    "FileReadToolNode": "Tool: Read Text (base_dir)",
    "FileWriteToolNode": "Tool: Write Text (base_dir)",
}

설명 요약

  1. 각 노드는 base_dir만 필수 입력으로 받고 tools_in은 옵션이며, 전달된 리스트 뒤에 ToolSpec 하나를 추가해 반환한다.

  2. 읽기/쓰기에서 append, encoding 같은 실행 세부는 node 입력이 아닌 parameters에 포함되어 LLM이 호출 시 결정한다.

  3. 모든 파일 경로는 base_dir 하위로만 접근하도록 _safe_join으로 보호했다.

  4. 반환 객체는 항상 JSON 직렬화 가능한 딕셔너리다.

  5. 이름과 설명은 고정 문자열로 제공한다. 필요하면 상위 레벨에서 다른 이름의 툴 노드를 추가로 만들면 된다.

scheme를 pydantic model에서 자동 생성하던데(주석 포함) 그 방법도 소개해줘

좋아. Pydantic(v2) 모델에서 JSON Schema를 자동 생성해, OpenAI function calling의 parameters로 바로 쓰는 방법을 정리해줄게. “주석 포함”은 런타임에서 코드 주석을 읽을 수는 없고, Field의 description/title/examples 같은 메타데이터로 스키마에 녹여 넣는 방식이 표준이야.

핵심 흐름

  1. Pydantic 모델 정의에 Field(description, examples 등)로 메타를 단다.

  2. model.model_json_schema()로 JSON Schema를 만든다.

  3. OpenAI 툴 파라미터는 전체 JSON Schema를 다 지원하진 않으므로, $defs/$ref를 인라인하고 불필요 키를 정리해 “간소 스키마”로 변환한다.

  4. 그 결과를 ToolSpec.parameters에 넣는다.

예제 코드(헬퍼 + 사용법)

# pydantic_schema_tools.py
# Python 3.10+, Pydantic v2
from __future__ import annotations

from typing import Any, Dict, Optional, List, Type, get_origin, get_args
from pydantic import BaseModel, Field, ConfigDict
from enum import Enum
import copy

# 1) 예시: 파일 툴용 파라미터 모델들

class ListFilesParams(BaseModel):
    model_config = ConfigDict(
        title="List files in a folder",
        json_schema_extra={"description": "List files under base_dir with optional glob pattern."},
    )
    folder_path: str = Field(..., description="Relative path from base_dir to list.")
    pattern: Optional[str] = Field(
        default=None,
        description="Optional glob pattern, e.g. '*.txt'. If omitted, list all files."
    )
    absolute: bool = Field(
        default=False,
        description="If true, return absolute paths; otherwise return file basenames.",
        json_schema_extra={"examples": [False]}
    )

class ReadFileParams(BaseModel):
    model_config = ConfigDict(
        title="Read text file",
        json_schema_extra={"description": "Read a text file under base_dir."},
    )
    file_path: str = Field(..., description="Relative path from base_dir to the file.")
    encoding: str = Field("utf-8", description="Text encoding, default 'utf-8'.")
    max_bytes: Optional[int] = Field(
        default=None,
        description="Optional safety limit in bytes to truncate large files.",
        ge=1
    )

class WriteMode(str, Enum):
    overwrite = "overwrite"
    append = "append"

class WriteFileParams(BaseModel):
    model_config = ConfigDict(
        title="Write text file",
        json_schema_extra={"description": "Write text content to a file under base_dir."},
    )
    file_path: str = Field(..., description="Relative path from base_dir to write to.")
    content: str = Field(..., description="Text content to write.")
    encoding: str = Field("utf-8", description="Text encoding, default 'utf-8'.")
    mode: WriteMode = Field(WriteMode.overwrite, description="Write mode: overwrite or append.")

# 2) 변환기: Pydantic schema -> OpenAI function parameters용 간소 스키마
# - $defs/$ref 인라인
# - OpenAI가 안 쓰는/문제되는 키 제거
# - 필요한 키만 남김: type, properties, required, additionalProperties, items, enum, description, minimum/maximum, minItems/maxItems, pattern, format 등

_ALLOWED_KEYS = {
    "type", "properties", "required", "additionalProperties", "items",
    "enum", "description", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
    "minLength", "maxLength", "pattern",
    "minItems", "maxItems",
    "format",  # 일부 모델은 format을 쓰기도 함
    "anyOf", "oneOf", "allOf",  # 가급적 피하되, 필요한 경우 유지
    "const", "default",
}

def _inline_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
    schema = copy.deepcopy(schema)
    defs = schema.pop("$defs", {})
    def resolve(node: Any) -> Any:
        if isinstance(node, dict):
            if "$ref" in node:
                ref: str = node["$ref"]
                if ref.startswith("#/$defs/"):
                    key = ref[len("#/$defs/"):]
                    target = copy.deepcopy(defs.get(key))
                    if target is None:
                        # dangling ref; fallback to empty schema
                        target = {}
                    return resolve(target)
                # Unknown ref: drop
                return {}
            # recurse
            return {k: resolve(v) for k, v in node.items()}
        if isinstance(node, list):
            return [resolve(v) for v in node]
        return node
    return resolve(schema)

def _prune_keys(node: Any) -> Any:
    if isinstance(node, dict):
        keep: Dict[str, Any] = {}
        for k, v in node.items():
            if k in _ALLOWED_KEYS:
                keep[k] = _prune_keys(v)
        # additional cleanup: if properties present but empty, ensure dict
        if "properties" in keep and keep["properties"] is None:
            keep["properties"] = {}
        return keep
    if isinstance(node, list):
        return [_prune_keys(v) for v in node]
    return node

def pydantic_to_tool_parameters(model: Type[BaseModel]) -> Dict[str, Any]:
    raw = model.model_json_schema()  # full JSON Schema with $defs/$ref
    inlined = _inline_refs(raw)
    pruned = _prune_keys(inlined)
    # Ensure top-level 'type'/'properties' exist per OpenAI expectations
    if "type" not in pruned and "properties" in pruned:
        pruned["type"] = "object"
    if "additionalProperties" not in pruned:
        pruned["additionalProperties"] = False
    return pruned

# 3) 사용 예: ToolSpec.parameters에 그대로 주입
#    아래는 앞서 만든 도구 파라미터 모델을 OpenAI function parameters로 바꾸는 예시.
if __name__ == "__main__":
    from pprint import pprint

    print("ListFilesParams -> tool parameters")
    pprint(pydantic_to_tool_parameters(ListFilesParams))

    print("\nReadFileParams -> tool parameters")
    pprint(pydantic_to_tool_parameters(ReadFileParams))

    print("\nWriteFileParams -> tool parameters")
    pprint(pydantic_to_tool_parameters(WriteFileParams))

툴 노드에서의 활용 예

# tool_nodes_pydantic_integration.py
from typing import Dict, Any, List
from dataclasses import dataclass
from pydantic_schema_tools import (
    pydantic_to_tool_parameters,
    ListFilesParams, ReadFileParams, WriteFileParams
)

@dataclass
class ToolSpec:
    name: str
    description: str
    parameters: Dict[str, Any]
    func: Any  # Callable[[Dict[str, Any]], Dict[str, Any]]

# 각 노드에서 base_dir 등 환경만 필수 입력으로 받고, parameters는 위 변환기로 만든다.

class FileListToolNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {"required": {"base_dir": ("STRING", {"default": "./"})}, "optional": {"tools_in": ("OPENAI_TOOLS",)}}
    RETURN_TYPES, RETURN_NAMES = ("OPENAI_TOOLS",), ("tools",)
    FUNCTION, CATEGORY = "append_tool", "OpenAI/Tools"

    def append_tool(self, base_dir: str, tools_in=None):
        parameters = pydantic_to_tool_parameters(ListFilesParams)
        def _impl(args: Dict[str, Any]) -> Dict[str, Any]:
            # args는 Pydantic 모델과 동일 구조로 들어옴
            folder = args.get("folder_path","")
            pattern = args.get("pattern") or ""
            absolute = bool(args.get("absolute", False))
            # ... base_dir 안전 처리 + 결과 반환 ...
            # 생략: 이전에 작성했던 _safe_join/실행 코드 재사용
            return {"result": []}
        spec = ToolSpec(
            name="list_files_in_folder",
            description="List files in a folder under the configured base_dir.",
            parameters=parameters,
            func=_impl
        )
        return ((tools_in if isinstance(tools_in, list) else []) + [spec],)

# Read/Write 노드도 동일하게 pydantic_to_tool_parameters(ReadFileParams / WriteFileParams) 사용

필수 포인트

  1. “주석 포함”에 해당하는 설명은 Field(description=…), model_config.json_schema_extra={"description": "…"}로 넣는다. 소스 코드의 주석(# …)은 런타임에서 자동 반영되지 않는다.

  2. Enum, Literal, Union(Optional 포함), 제약조건(ge/le, minLength 등)은 Pydantic이 JSON Schema로 내보내며, 변환기에서 그대로 보존한다.

  3. OpenAI의 function calling은 JSON Schema의 모든 키를 지원하지 않는다. $ref/$defs를 제거하고 필요한 키만 남기는 정리가 안정적이다. 위 헬퍼를 그대로 쓰면 된다.

  4. 중첩 모델도 model_json_schema()가 $defs/$ref로 생성하므로, 변환기에서 인라인되어 하나의 parameters로 들어간다.

추가 팁

  • 필드 예시를 보여주고 싶으면 Field(json_schema_extra={"examples": […]})로 넣으면 schema에 노출된다.

  • 모델 레벨 title/description은 model_config에 둔다(ConfigDict).

  • 컬렉션 타입의 스키마가 필요하면 TypeAdapter를 쓸 수 있다. 예: TypeAdapter(list[ReadFileParams]).json_schema(). 다만 function parameters 최상위는 보통 object를 기대하므로 직접 적합한 형태로 감싸는 게 안전하다.

  • v1을 쓴다면 BaseModel.schema()가 동등 역할을 하지만, v2로 통일하는 걸 추천.

이렇게 만들면, 필드 설명과 예시까지 담긴 Pydantic 기반 스키마를 안전하게 툴 parameters로 사용할 수 있어.

별로 자동으로 해주는게 아닌가>

맞아. Pydantic만 “그냥 쓰면” 완전히 자동은 아니야. 하지만 함수의 타입 힌트와 docstring을 읽어 JSON Schema와 ToolSpec을 자동 생성하도록 래퍼를 두면 사실상 “함수만 쓰면 툴이 생기는” 수준까지 갈 수 있어. 아래 코드는 그걸 해주는 최소 구현이야.

핵심 아이디어

  1. 함수 시그니처(type hints) → 동적 Pydantic 모델 생성(create_model)

  2. docstring(요약/파라미터 설명) → description/Field.description에 주입

  3. Pydantic의 model_json_schema()를 OpenAI에 맞게 정리($defs 인라인 등)

  4. base_dir 같은 “환경 파라미터”는 스키마에서 제외(exclude)하고, 실행시에만 주입

코드 한 방에 끝

# auto_toolgen.py
# Python 3.10+, Pydantic v2
from __future__ import annotations

import os
import re
import glob
import json
import inspect
import copy
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Callable, Type

try:
    from pydantic import BaseModel, Field, ConfigDict, create_model
except ImportError as e:
    raise RuntimeError("Pydantic v2가 필요합니다. `pip install pydantic>=2`") from e

try:
    import docstring_parser  # optional, 없으면 설명은 최소화
except Exception:
    docstring_parser = None

@dataclass
class ToolSpec:
    name: str
    description: str
    parameters: Dict[str, Any]
    func: Any  # Callable[[Dict[str, Any]], Dict[str, Any]]

# ---------- JSON Schema helpers (OpenAI function parameters용 간소화) ----------

_ALLOWED_KEYS = {
    "type", "properties", "required", "additionalProperties", "items",
    "enum", "description", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
    "minLength", "maxLength", "pattern",
    "minItems", "maxItems",
    "format", "const", "default", "oneOf", "anyOf", "allOf",
}

def _inline_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
    schema = copy.deepcopy(schema)
    defs = schema.pop("$defs", {})
    def resolve(node: Any) -> Any:
        if isinstance(node, dict):
            if "$ref" in node:
                ref = node["$ref"]
                if isinstance(ref, str) and ref.startswith("#/$defs/"):
                    key = ref[len("#/$defs/"):]
                    return resolve(copy.deepcopy(defs.get(key, {})))
                return {}  # unknown ref → drop
            return {k: resolve(v) for k, v in node.items()}
        if isinstance(node, list):
            return [resolve(v) for v in node]
        return node
    return resolve(schema)

def _prune_keys(node: Any) -> Any:
    if isinstance(node, dict):
        keep = {}
        for k, v in node.items():
            if k in _ALLOWED_KEYS:
                keep[k] = _prune_keys(v)
        if "properties" in keep and keep["properties"] is None:
            keep["properties"] = {}
        return keep
    if isinstance(node, list):
        return [_prune_keys(v) for v in node]
    return node

def pydantic_to_tool_parameters(model: Type[BaseModel]) -> Dict[str, Any]:
    raw = model.model_json_schema()
    inlined = _inline_refs(raw)
    pruned = _prune_keys(inlined)
    if "type" not in pruned and "properties" in pruned:
        pruned["type"] = "object"
    if "additionalProperties" not in pruned:
        pruned["additionalProperties"] = False
    return pruned

# ---------- Docstring parsing ----------

def parse_docstring(fn: Callable) -> tuple[str, dict[str, str]]:
    """
    returns: (short_description, {param_name: description})
    """
    doc = inspect.getdoc(fn) or ""
    if not doc or docstring_parser is None:
        return "", {}
    try:
        parsed = docstring_parser.parse(doc)
        short = (parsed.short_description or "").strip()
        pmap = {p.arg_name: (p.description or "").strip() for p in parsed.params if p.arg_name}
        return short, pmap
    except Exception:
        return "", {}

# ---------- Dynamic model builder from a function signature ----------

def model_from_signature(
    fn: Callable,
    exclude: Optional[set[str]] = None,
    param_docs: Optional[dict[str, str]] = None,
    title: Optional[str] = None,
    top_description: Optional[str] = None,
) -> Type[BaseModel]:
    """
    Build a Pydantic model from fn(**kwargs), excluding some params (env-only).
    - exclude: names not exposed to LLM (예: base_dir)
    - param_docs: per-param descriptions
    - title/top_description: model-level title/description
    """
    exclude = exclude or set()
    hints = typing.get_type_hints(fn, include_extras=True)
    sig = inspect.signature(fn)

    fields: dict[str, tuple[Any, Any]] = {}
    for name, p in sig.parameters.items():
        if name in exclude:
            continue
        ann = hints.get(name, Any)
        if p.default is inspect.Parameter.empty:
            default = ...
        else:
            default = p.default
        desc = (param_docs or {}).get(name)
        if desc:
            fields[name] = (ann, Field(default, description=desc))
        else:
            fields[name] = (ann, default)

    model_config = ConfigDict()
    schema_extra = {}
    if top_description:
        schema_extra["description"] = top_description
    model_config["title"] = title or (fn.__name__.replace("_", " ").title())
    if schema_extra:
        model_config["json_schema_extra"] = schema_extra

    M = create_model(
        fn.__name__.title() + "Params",
        __config__=model_config,
        **fields,
    )
    return M

# ---------- Tool factory: function -> ToolSpec ----------

def tool_from_callable(
    fn: Callable,
    *,
    name: Optional[str] = None,
    description: Optional[str] = None,
    exclude_params: Optional[set[str]] = None,
    env: Optional[dict[str, Any]] = None,
) -> ToolSpec:
    """
    Turn a Python callable into ToolSpec.
    - name/description 미지정 시: 함수명/도큐스트링으로 자동 결정
    - exclude_params: schema에서 빼되 실행 시 env로 주입할 파라미터
    - env: 실행 시 강제 주입할 값들(예: base_dir)
    """
    short, pdocs = parse_docstring(fn)
    model = model_from_signature(
        fn, exclude=exclude_params, param_docs=pdocs,
        title=(description or short or fn.__name__),
        top_description=(description or short or "")
    )
    params = pydantic_to_tool_parameters(model)
    tool_name = name or fn.__name__
    tool_desc = description or short or (fn.__name__.replace("_", " ").capitalize())

    def executor(args: Dict[str, Any]) -> Dict[str, Any]:
        try:
            kwargs = {}
            kwargs.update(args or {})
            if env:
                # env keys override task args
                kwargs.update(env)
            result = fn(**kwargs)
            if isinstance(result, dict):
                return result
            return {"result": result}
        except Exception as e:
            return {"error": repr(e)}

    return ToolSpec(name=tool_name, description=tool_desc, parameters=params, func=executor)

# ---------- Safe filesystem primitives (예시 함수들) ----------

def _safe_join(base_dir: str, path: str) -> Optional[str]:
    base = os.path.abspath(base_dir or "")
    target = os.path.abspath(os.path.join(base, path or ""))
    try:
        common = os.path.commonpath([base, target])
    except Exception:
        return None
    return target if (base and common == base) else None

def list_files_in_folder(*, folder_path: str, pattern: Optional[str] = None, absolute: bool = False, base_dir: str) -> dict:
    """
    List files in a folder under base_dir.
    Args:
        folder_path: Relative path from base_dir to list.
        pattern: Optional glob pattern like '*.txt'.
        absolute: If true, return absolute paths; otherwise basenames.
        base_dir: Environment-provided root directory. Not exposed to the LLM schema.
    """
    resolved = _safe_join(base_dir, folder_path)
    if not resolved or not os.path.isdir(resolved):
        return {"error": "invalid_folder", "result": []}
    target = os.path.join(resolved, pattern) if pattern else os.path.join(resolved, "*")
    files = [p for p in glob.glob(target) if os.path.isfile(p)]
    items = [os.path.abspath(p) if absolute else os.path.basename(p) for p in files]
    return {"result": sorted(items)}

def read_text_file(*, file_path: str, encoding: str = "utf-8", max_bytes: Optional[int] = None, base_dir: str) -> dict:
    """
    Read a text file under base_dir.
    Args:
        file_path: Relative path from base_dir to the file.
        encoding: Text encoding, default 'utf-8'.
        max_bytes: Optional safety limit in bytes; if set, truncates.
        base_dir: Environment-provided root directory. Not exposed to the LLM schema.
    """
    resolved = _safe_join(base_dir, file_path)
    if not resolved or not os.path.isfile(resolved):
        return {"error": "invalid_file", "result": ""}
    try:
        if isinstance(max_bytes, int) and max_bytes > 0:
            with open(resolved, "rb") as fb:
                data = fb.read(max_bytes)
            text = data.decode(encoding, errors="replace")
        else:
            with open(resolved, "r", encoding=encoding, errors="replace") as f:
                text = f.read()
        return {"result": text}
    except Exception as e:
        return {"error": repr(e), "result": ""}

def write_text_file(*, file_path: str, content: str, encoding: str = "utf-8", mode: str = "overwrite", base_dir: str) -> dict:
    """
    Write text content to a file under base_dir.
    Args:
        file_path: Relative path from base_dir to write to.
        content: Text content to write.
        encoding: Text encoding, default 'utf-8'.
        mode: 'overwrite' or 'append'.
        base_dir: Environment-provided root directory. Not exposed to the LLM schema.
    """
    resolved = _safe_join(base_dir, file_path)
    if not resolved:
        return {"error": "invalid_path", "result": False}
    try:
        os.makedirs(os.path.dirname(resolved), exist_ok=True)
        m = "a" if str(mode).lower() == "append" else "w"
        with open(resolved, m, encoding=encoding, errors="replace") as f:
            f.write(content)
        return {"result": True}
    except Exception as e:
        return {"error": repr(e), "result": False}

# ---------- Example: generate ToolSpecs automatically ----------

def build_filesystem_tools(base_dir: str) -> List[ToolSpec]:
    tools: List[ToolSpec] = []
    # base_dir는 스키마에서 제외하고 실행시에 env로 주입
    env = {"base_dir": base_dir}
    exclude = {"base_dir"}

    tools.append(tool_from_callable(
        list_files_in_folder,
        name="list_files_in_folder",
        exclude_params=exclude,
        env=env,
    ))
    tools.append(tool_from_callable(
        read_text_file,
        name="read_text_file",
        exclude_params=exclude,
        env=env,
    ))
    tools.append(tool_from_callable(
        write_text_file,
        name="write_text_file",
        exclude_params=exclude,
        env=env,
    ))
    return tools

if __name__ == "__main__":
    # 데모: JSON Schema 미리보기
    tlist = build_filesystem_tools("./samples")
    for t in tlist:
        print("\n---", t.name, "---")
        print("description:", t.description)
        print(json.dumps(t.parameters, ensure_ascii=False, indent=2))

이게 “자동으로 해주는가?”에 대한 현실적인 해답이야.

  • 함수에 타입 힌트와 간단한 docstring만 달면 ToolSpec(parameters 포함)이 자동 생성된다.

  • base_dir 같은 환경값은 exclude_params로 스키마에서 숨기고 env로 주입된다.

  • docstring_parser가 있으면 파라미터 설명까지 스키마에 들어가고, 없으면 타입 힌트 기반 최소 스키마만 만들어진다.

운영 팁

  • 파라미터 설명을 확실히 넣고 싶으면 Google/Numpy 스타일 docstring을 권장.

  • Enum, Literal, Optional, list[str] 같은 타입 힌트를 적극 사용하면 스키마 품질이 올라간다.

  • 더 자동화하고 싶으면, 함수 이름으로 name을 그대로 쓰되 prefix를 붙이는 규칙 정도만 추가하면 된다.

Categories:

Updated: