
    ԯj4             "          U d Z ddlZddlZddlZddlZddlmZ ddlmZm	Z	m
Z
mZmZmZ ddlZddlZddlmZ  ej"                  e      Z G d de      Zdefd	Z eh d
      Zdede
eef   defdZde
eef   de
eef   dededdf
dZ ej:                         Zddee
   ddfdZdee
   fdZ ddedee
   de
fdZ!	 ddedeee      de
fdZ"da# ejH                         Z%dZ&da'de
ee(f   fdZ)ddZ*d Z+da,ee   e-d<    G d  d!e      Z.da/ee.   e-d"<   dee   fd#Z0d$d!d%ed&e
eef   fd'Z1dd(Z2dd)Z3d%ed&efd*Z4d+ Z5defd,Z6i d-d.d/d0d1d2d3d4d0d1d5d6d7d0d1d8d9d7d0d1d:d;d7d0d1d<d=d>d0d1d?d@d7d0d1dAdBd/d0d1dCdDd7dEd1dFdGd/dHd1dIdJd/dHd1dKdLd/d0d1dMdNd7dEd1dOdPd7dEd1dQdRd7dEd1dSdTd7dUd1dVdWd7dEd1dXd7dEd1dYdZdEd1d[Z7e
ee
eef   f   e-d\<   de
ee
eef   f   fd]Z8 e8       Z9d7d^d_d`dadbd_d`dcdbd_d`d/ddded`dfdbd_d`dZdgded`dhddded`diddded`djdkd_d`dldmd_d`dndodpd`dqdrdpd`dsdtdud`gZ: e;       Z<e;e   e-dv<   ddedwee
   defdxZ= eh dy      Z>d%ed&e
eef   ddfdzZ?dsd{iZ@e
ee(f   e-d|<   d%edee(   fd}ZAi ZBe
eef   e-d~<    eh d      ZCddddddddddddgZDd%ed&e
eef   ddfdZE eh d      ZF edh      ZGdededefdZHdedefdZI e;       ZJe;e   e-d<   dedee   defdZK e;       ZLe;e   e-d<   d%ed&e
eef   ddfdZMdddee
eef      dedededdf
dZNde
eef   dede
eef   fdZO	 	 	 	 	 ddddddddededdde
eef   dwee
   dedee
   dePdee(   dedee	e
eef   gef      dee
eef      dee(   deeP   de
eef   fdZQ	 	 	 ddddedededwee
   dee
   dePdedefdZR	 	 	 	 	 ddddedee
eef      dee
eef      dwee
   dee
   dedePdedefdZSy)u%  LiteLLM + Opik 통합 LLM 클라이언트.

모든 텍스트 LLM 호출을 LiteLLM Router를 통해 수행하고,
Opik callback으로 자동 추적한다.

기존 GeminiTextClient / OpenAIClient / gemini_key_pool / llm_logger를 대체.
이미지 생성(gemini_image_client)은 별도 유지.
    N)Path)AnyCallableDictList
NamedTupleOptional)Routerc                       e Zd ZdZy)EmptyLLMResponseuP  모델이 200 으로 답했는데 내용이 비었다.

    원인은 이것만으로 모른다 — 검열일 수도, 토큰 소진일 수도, 제공자
    사정일 수도 있다. 그래서 메시지에 `finish_reason` 을 같이 싣는다.
    ``RuntimeError`` 하위라 기존에 이것을 잡던 자리는 그대로 잡는다.
    N)__name__
__module____qualname____doc__     P/Users/manta/Documents/Projects/TheRoad-I1/backend/app/modules/llm/llm_client.pyr   r      s    r   r   returnc                      t         j                  j                  d      } |  | j                         j	                         dv S 	 ddlm} t        t        |dd            S # t        $ r Y yw xY w)u  local jsonschema 검증 toggle 평가 (problems.md #13, review I1).

    우선순위: ``LLM_LOCAL_SCHEMA_VALIDATE`` ENV → ``settings.llm_local_schema
    _validate`` (Pydantic). 둘 다 미설정 시 default True. provider strict mode
    (LiteLLM ``response_format=json_schema strict=True``) 가 일부 provider 에서
    일부 필드만 enforce 할 수 있으므로 JSON 파싱 후 ``jsonschema.validate`` 로
    한 번 더 shape 검증한다. 운영 중 schema 배포 시 즉시 disable 가능.
    LLM_LOCAL_SCHEMA_VALIDATE)1trueyesonr   settingsllm_local_schema_validateT)
osenvirongetstriplowerapp.core.configr   boolgetattr	Exception)rawr   s     r   !_is_local_schema_validate_enabledr(   !   sf     **..4
5C
yy{  "&@@@,GH&A4HII s   A 	A+*A+>   input	arguments
parameterspayloadschemac                 b   t        | t              rt        |       dk7  r| S | j                         | j	                         c\  }\  }|t
        vst        |t              s| S t        |t              r|j                  d      nd}t        |t              r||v r| S t        j                  d|       |S )u  tool-call 래퍼 한 겹을 벗긴다 — 스키마가 그 키를 원하지 않을 때만.

    보수적으로만 벗긴다: ①최상위가 dict 이고 키가 **정확히 하나** ②그 키가
    알려진 래퍼 이름 ③스키마의 properties 에 그 키가 **없다** ④안쪽이 dict.
    넷을 다 만족하면 스키마가 그 키를 담을 수 없으므로 래퍼가 확실하다.
    하나라도 어긋나면 원본을 그대로 돌려준다.
       
propertiesNu-   structured output: '%s' 래퍼 한 겹 벗김)	
isinstancedictlenkeysvalues_TOOL_ENVELOPE_KEYSr    loggerinfo)r,   r-   keyinnerpropss        r   _unwrap_tool_enveloper<   =   s     gt$G(9||~w~~'7FSHU
%%Zt-D(264(@FJJ|$dE%3%<
KK?ELr   response_schemastepschema_namec                   ddl m}m} t               syt	        |t
              r|sy	 t        j                  | |       y# t        j                  $ rJ}dj                  d |j                  D              xs d} |d| d	| d
| d|j                         |d}~wt        j                  $ r!} |d| d| d|j                         |d}~ww xY w)uh  ``jsonschema.validate`` wrapper — 실패 시 ``SchemaValidationError`` raise.

    ENV ``LLM_LOCAL_SCHEMA_VALIDATE=false`` 면 no-op. ``response_schema`` 가 빈 dict
    이거나 비-dict 면 검증 skip (legacy/free-form caller 보호). ``ValidationError``
    의 path/message 를 보존하여 Tier 2/3 로직과 logger 가 root cause 진단 가능.
    r   )InvalidSchemaErrorSchemaValidationErrorN)instancer-   /c              3   2   K   | ]  }t        |        y wN)str).0ps     r   	<genexpr>z)_validate_local_schema.<locals>.<genexpr>g   s     :(91A(9   z<root>z(Local schema validation failed for step=z schema=z	 at path=z: z!Invalid jsonschema spec for step=z (schema_id=z): )app.modules.llm.safetyrA   rB   r(   r1   r2   
jsonschemavalidateValidationErrorjoinabsolute_pathmessageSchemaError)r,   r=   r>   r?   rA   rB   excpaths           r   _validate_local_schemarV   Q   s     Q,.ot,OW_E%% xx:(9(9::Fh#6tfH[M RfBs{{m-
 	 !!  !/v\+c{{m
 		s$   A   CABC.C

Cmetac                     | t         _        y)uR   현재 스레드에 Opik metadata context 설정. call_* 호출 시 자동 병합.N)_thread_local	opik_meta)rW   s    r   set_opik_contextr[   y   s
    "Mr   c                  $    t        t        dd       S )NrZ   )r%   rY   r   r   r   _get_thread_opik_metar]   ~   s    =+t44r   opik_metadatac                    dd| gii}t               }|rh|j                  dg       }|j                         D ci c]  \  }}|dk7  s|| }}}|d   j                  |       |r|d   d   j	                  |       |rh|j                  dg       }|j                         D ci c]  \  }}|dk7  s|| }}}|d   j                  |       |r|d   d   j	                  |       	 ddlm} t        |dd      rddlm	}	m
}
m} |d   } |
       }|d	|j                  i|d
<   |j                  dd       |j                  dd        |	|       }|j                  d      xs g D ]!  } ||      s||vs|j                  |       # ||d<   |S c c}}w c c}}w # t        $ r!}t         j#                  d|       Y d}~|S d}~ww xY w)u   Opik metadata 구성 — thread-local context 자동 병합.

    우선순위: 명시적 opik_metadata > thread-local context > step tag만
    opiktagsr   r   opik_trace_v2_enabledF)build_axis_tagscurrent_traceis_axis_tagNtrace_idcurrent_span_data
trace_name
session_id)opu5   _build_opik_metadata v2 배선 실패 (non-fatal): %s)r]   r    itemsupdateextendr#   r   r%   app.modules.llm.opik_tracerc   rd   re   uidpopappendr&   r7   debug)r>   r^   metadatathread_meta
extra_tagskvmergedr   rc   rd   re   
opik_blockparent	axis_tagstagrT   s                    r   _build_opik_metadatar}      s   
 $()H ()K __VR0
#.#4#4#6F#641a!v+!Q$#6F'VV$++J7 "&&vr2
#0#6#6#8H#841aAK!Q$#8H'VV$++J7$S,84e<= = "&)J #_F!3=vzz2J
./NN<.NN<. (40I"v.4"4s#9(<$$S) 5 "+Jv Os G I\  SLcRROSs<   FF#F1F'B	F 1F 6F 	G%GGrj   ra   c                 @    t        | |rdt        |      i      S d      S )u  `router_completion` 을 **직접** 부르는 자리가 쓰는 표준 metadata.

    `call_structured` 계열은 내부에서 `_build_opik_metadata` 를 타지만,
    router 를 직접 부르는 자리는 그것을 건너뛴다. 그래서 나가는 태그가 맨
    이름 하나뿐이었고(`["ref_validation"]`), 축도 안 갈리고 부모 trace 에도
    안 붙어 `chat.completion` 으로 홀로 남았다.

    2026-08-25 실측(최소 검증판 주행): span 26/252 가 스텝 축을 잃었고
    그중 14 건이 참조 검증이었다. 이 함수를 쓰면 축 태그·thread_id·부모
    trace 가 다른 호출과 **같은 규칙**으로 실린다.
    ra   N)r}   list)rj   ra   s     r   build_call_metadatar      s$      DVT$Z$8KKdKKr   F"_theroad_opik_cache_fields_wrapperc                     d }i }t        | dd      }d}t        |t              r|j                  d      }n|t        |dd      } ||      }|||d<    |t        | dd            }|||d<   |S )u  usage 에서 캐시로 재사용된 입력 토큰을 꺼낸다 (없으면 빈 dict).

    두 자리를 본다 — `prompt_tokens_details.cached_tokens` (OpenAI 형식,
    litellm 이 Gemini 의 cachedContentTokenCount 도 여기 채운다) 와
    `cache_read_input_tokens` (Anthropic 형식). 값이 없으면 키를 만들지
    않는다: 없는 것을 0 으로 적으면 적중률 0% 라는 거짓 기록이 남는다.
    c                 H    t        | t              st        | t              sy | S rF   )r1   r$   int)values    r   _int_or_nonez&_cache_fields_of.<locals>._int_or_none   s    eT"*UC*@r   prompt_tokens_detailsNcached_tokenscache_read_input_tokensr%   r1   r2   r    )usager   fieldsdetailscachedreads         r   _cache_fields_ofr      s    
  Fe4d;GF'4 _-		/48&!F"('@$GHD,0()Mr   c                     da 	 ddlm}  t        | dd      t        j                  d       yt        t        d      rda yfd	}t        |t        d       |_	        	 || _
        da t        j                  d       y# t        $ r }t        j                  d|       Y d}~yd}~ww xY w# t        $ r }t        j                  d
|       Y d}~yd}~ww xY w)ut  litellm 의 Opik 콜백이 버리는 캐시 항목을 usage 에 되살린다.

    `litellm/integrations/opik/utils.py` 의 `create_usage_object` 는 세 값
    (completion/prompt/total)만 담는다. 캐시로 재사용된 입력 토큰은 거기서
    사라져 Opik span 에 남지 않는다 — 값은 제공사가 주는데 기록만 없다.
    그 상태로는 프롬프트 조립 순서를 바꿔도 효과를 잴 수 없다.

    설치본 파일은 고치지 않는다(재설치 때 날아간다). 호출 지점이
    `utils.create_usage_object(...)` 로 모듈 속성을 매번 찾으므로 속성만
    바꿔 두면 걸린다. 원 함수는 `__wrapped__` 로 들고 있고, 표식을 보고
    두 번 감싸지 않는다.

    배선이 안 걸리면 **ERROR 로 남기고** `_opik_cache_wiring_ok` 를 False 로
    둔다. 이 실패는 조용하면 안 된다 — 토큰 기록은 그대로 남고 캐시 칸만
    비어서, 나중에 보면 "캐시가 안 걸린 주행"과 구분이 안 된다. 다만 호출
    자체는 죽이지 않는다(기록 보강이 본 작업을 막지 않는다는 기존 관례).
    Fr   )utilsu   Opik usage 캐시 항목 배선 실패 — litellm opik utils 없음: %s. 캐시 적중은 기록되지 않는다(토큰 세 값은 남는다).Ncreate_usage_objectu   Opik usage 캐시 항목 배선 실패 — create_usage_object 없음 (litellm 구조 변경). 캐시 적중은 기록되지 않는다(토큰 세 값은 남는다).Tc                      |       }	 |j                  t        |              |S # t        $ r!}t        j	                  d|       Y d }~|S d }~ww xY w)Nu*   Opik usage 캐시 항목 추출 실패: %s)rl   r   r&   r7   warning)r   
usage_dictrT   originals      r   r   z4_wrap_opik_usage_object.<locals>.create_usage_object3  sZ    e_
	N.u56   	NNNGMM	Ns   ' 	AAAu   Opik usage 캐시 항목 배선 실패 — 함수 교체 불가: %s. 캐시 적중은 기록되지 않는다(토큰 세 값은 남는다).uD   Opik usage 에 캐시 항목 기록 켬 (create_usage_object 래핑))_opik_cache_wiring_oklitellm.integrations.opikr   r&   r7   errorr%   _OPIK_CACHE_WRAP_MARKsetattr__wrapped__r   r8   )
opik_utilsrT   r   r   s      @r   _wrap_opik_usage_objectr     s    & "A z#8$?H-	. 	x.6 $ !6=&.#)<
& !
KKVWI  TUX	Z 		<  TUX	Z 		s/   B "B- 	B*
B%%B*-	C6CCc                  \   t         ry t        5  t         r
	 d d d        y ddlm}  | j                  s| j
                  r| j                  r| j                  t        j                  d<   | j
                  r| j
                  t        j                  d<   | j                  t        j                  d<   | j                  t        j                  d<   t                dgt        _        t        j                  d| j                  | j
                  xs d	t        rd
nd       da d d d        y # 1 sw Y   y xY w)Nr   r   OPIK_API_KEYOPIK_URL_OVERRIDEOPIK_WORKSPACEOPIK_PROJECT_NAMEr`   z=Opik callback enabled (project: %s, url: %s, cache_wiring=%s)cloudokfailedT)_opik_initialized
_init_lockr#   r   opik_api_keyopik_url_overrider   r   opik_workspaceopik_project_namer   litellm	callbacksr7   r8   r   r   s    r   
_init_opikr   I  s    	 
 	-   H$>$>$$-5-B-B

>*))2:2L2L

./+3+B+BBJJ'(.6.H.HBJJ*+ $%!'GKKO****5g-8	=
 !- 
s   D"C:D""D+_routerc                   ,    e Zd ZU dZee   ed<   eed<   y)_RouterBindinguc  Router 와 **그것을 지을 때 쓴 슬롯**을 하나로 묶는다.

    [2026-08-01 A5 후속, Codex 재확인 BLOCKING] Router 는 어느 슬롯 키로
    지어졌는지 기록하지 않았고, ``_completion`` 은 호출 직전 전역 활성 슬롯을
    다시 읽어 그것을 자기 슬롯으로 삼았다. 그래서 이런 창이 열린다:

        ① 한 요청이 primary 키로 지은 Router 를 이미 들고 있다
        ② 다른 요청이 전역을 secondary 로 옮긴다
        ③ ①이 그 stale Router 로 호출한다 → 실제로는 primary 로 나간다
        ④ primary billing 실패가 "secondary 실패"로 보고된다
        ⑤ 마지막 슬롯 소진 판정 — **보조 키를 한 번도 안 써 보고 죽는다**

    슬롯과 Router 를 짝으로 다루면 ③에서 실패한 슬롯을 정확히 지목한다.
    slotrouterN)r   r   r   r   r	   rG   __annotations__r
   r   r   r   r   r   j  s     3-Nr   r   _bindingc                  T   t        t              j                         j                  j                  j                  j                  dz  } g }| j	                         s|S | j                  d      j                         D ]  }|j                         }|r|j                  d      sd|vr+|j                  d      \  }}}|j                         }|j                         j                  d      j                  d      }|j                  d      s|s||vs|j                  |        |S )	u+   backend/.env 에서 GEMINI_API_KEY* 수집.z.envzutf-8)encoding#="'GEMINI_API_KEY)r   __file__resolverz   exists	read_text
splitlinesr!   
startswith	partitionrq   )env_filer4   linerv   _rw   s         r   _load_gemini_keysr     s    H~%%'..55<<CCfLHD??""G"4??Azz|ts+s$..%1aGGIGGIOOC &&s+<<()a}A B Kr   bindingmodelkwargsc           
      R   ddl m} ddlm} ddlm} ddlm} t        ||       t               }| |        t        d|j                               }d}	t        |      D ][  }
	  |d| d	
        |d|dt        |xs d            }	  | j                  j                  di |}||j#                          |c S  |	J |	# t        $ r ||j!                  d        w xY w# t        $ r9}|}	|j%                  |d| d	| j&                        s t)               } Y d}~d}~ww xY w)u  Router 호출 — OpenAI **키 수준** 실패면 다음 키 슬롯으로 재시도.

    전환 대상은 billing hard limit / quota 소진 / 401·403 뿐이다. 그 외
    실패(타임아웃·5xx·단순 429·스키마 위반)는 그대로 올려 기존 재시도·
    Tier fallback 경로가 처리하게 둔다. 전환이 일어나면 Router 가 무효화
    되므로 `_get_router_binding()` 로 새 키가 박힌 짝을 다시 받는다.

    ★``binding`` 을 받는다 (2026-08-01 A5 후속) — 전역 활성 슬롯을 호출 직전에
    다시 읽으면, stale Router 를 든 요청이 **다른 요청이 옮겨 놓은 슬롯**을
    자기 것으로 착각한다. 그러면 primary 실패가 secondary 실패로 보고되어
    보조 키를 한 번도 안 써 보고 죽는다. 실패한 슬롯은 그 Router 를 지은
    슬롯이지, 지금 전역이 가리키는 슬롯이 아니다.

    ★**정지 관문이 여기 있다** (2026-08-26). 이미지 호출은
    `reserve_current_call` 이라는 공통 길목이 있는데 글 호출에는 없어서,
    `scene_detail` 같은 글 팬아웃은 정지를 전혀 못 들었다.

    ★처음엔 `router_completion` 에 걸었는데 **헛자리였다** — 그 함수의
     docstring 이 「Router 를 거치는 유일한 호출 경로」라고 적혀 있어 믿었지만,
     `call_structured`·`call_text`·`call_multiturn` 은 전부 이 함수를 **직접**
     부른다. 문서를 믿지 말고 호출부를 세야 했다. 진짜 공통 길목은 여기다.

    ★키 전환 루프 **앞**에 둔다 — 논리 호출당 한 번만 보면 된다. 루프 안에
     두면 슬롯 수만큼 조회가 늘어난다.
    r   openai_keys)reserve_current_research_call)
GRAIN_SLOT)record_sendNr/   zllm_client._completion[])sourcellmzllm_client._completion )kindgranularityr   r   zrouter.completion raisedzrouter.completion[)whereattempted_slotr   )app.corer   app.core.research_call_budgetr   app.core.send_ledgerr   r   _sanitize_kwargs_for_model_current_stop_checkmax
slot_countrangerG   r   
completionr&   r   r   failover_onr   _get_router_binding)r   r   r   r   r   _GRAIN_SLOT_record_send
stop_checkattemptslastr   	_vlm_send_resrT   s                 r   _completionr     sP   4 %K>@ uf-$&J1k,,./H$(D8_%	, *0q9; % /s5;B7G	I0w~~00:6: $K= N 
J#  ($$%?@	  	,D**/wa8&|| +  )+G	,s0   #&C$
C&C$C!!C$$	D&-/D!!D&c                  6    da dat        j                  d       y)u  다음 호출에서 Router 를 다시 짓게 한다.

    OpenAI 키 슬롯이 바뀌면 deployment 에 박힌 api_key 가 옛 키라 그대로
    두면 전환이 무의미하다. 대입은 GIL 하에서 원자적이고, 실제 재빌드는
    `_get_router_binding` 의 double-checked locking 이 처리한다 — 여기서 `_init_lock`
    을 잡으면 호출 중 전환과 엮여 교착이 될 수 있다.
    Nu<   LiteLLM Router 무효화 — OpenAI 키 슬롯 전환 반영)r   r   r7   r8   r   r   r   _invalidate_routerr     s     GH
KKNOr   c                  F   ddl m}  t        }||j                  | j	                         k(  r|S t
        5  t        }|(|j                  | j	                         k(  r|cddd       S t                t                t        }|t        d      |cddd       S # 1 sw Y   yxY w)u  Router 와 그 생성 슬롯을 **짝으로** 받는다.

    ★캐시 판정을 "객체가 있는가"가 아니라 **"그 Router 를 지은 슬롯이 아직
    현재 슬롯인가"** 로 한다. 전환 훅(`_invalidate_router`)은 `_active_index`
    가 바뀐 **뒤 락 밖에서** 돌기 때문에, 그 사이에 다른 스레드가 옛 Router 를
    그대로 받아 갈 수 있다. 슬롯을 대조하면 그 창이 닫힌다.
    r   r   Nu1   LiteLLM Router binding 이 생성되지 않았다)	r   r   r   r   active_slotr   r   _build_routerRuntimeError)r   bbuilts      r   r   r     s     %A};#:#:#<<	=QVV{'>'>'@@ 
 	=RSS 
s   'B%(BB c                 4    t        t               | d| i|      S )u  Router 를 거치는 **유일한** 호출 경로 — 키 슬롯 전환이 항상 걸린다.

    [2026-08-01 A5 마무리] 이전에는 `_get_router()` 로 원시 Router 를 꺼내
    `.completion(...)` 을 직접 부르는 소비자가 넷 있었고, 그 경로에서는 1차 키가
    billing 으로 죽어도 **보조 키를 한 번도 안 써 보고** 예외가 그대로 올라갔다
    (직접 재현: active_slot 이 primary 그대로, Router 빌드 1회).

    "원시 Router 를 꺼내지 마라"는 규칙으로 두면 또 생긴다 — 실제로 네 번
    생겼다. 그래서 `_get_router()` 자체를 없애고 이 함수만 남긴다.

    ★위 「유일한 경로」는 **원시 Router 를 꺼내지 않는다**는 뜻이지 모든
     호출이 이 함수를 지난다는 뜻이 아니다. `call_structured` 등은 아래
     `_completion` 을 직접 부른다 — 정지 관문이 거기 있는 이유다.
    r   )r   r   r   r   s     r   router_completionr     s!     *,egu5O5OPPr   c                  <    	 ddl m}   |        S # t        $ r Y yw xY w)u[   이 스레드에 걸린 정지 확인. 순환 import 를 피해 호출 시점에 읽는다.r   get_current_stop_checkN)app.core.image_call_budgetr   r&   r   s    r   r   r   &  s%    E%'' s    	c            
         ddl m}  g }t               }|s| j                  r| j                  g}d| j                  fd| j
                  fd| j                  fd| j
                  fg}|D ]:  \  }}t        |      D ]'  \  }}|j                  |d| |dd	| d
| id       ) < ddl	m
} |j                  t               |j                         \  }	}
|
rddi}dt        t        | dd      xs d      i}dt        t        | dd      xs d      i}dt        t        | dd      xs d      i}|j                  dd| j                    |
d||d       | j                   d|f| j                   d|f| j                   d|fddi fddi f| j                   d|ffD ]$  \  }}}|j                  |d| |
d||d       & t        | dd       xs d j#                         }| j$                  r2|r0|j                  d!d"| | j$                  | j&                  dd#d       | j(                  rId$| j*                  fd%| j,                  ffD ]*  \  }}|j                  |d&| | j(                  dd'd       , dd(l}d|_        t3        |d)| j4                  | j6                  d*+      at;        |	t8        ,      at>        jA                          |D ]C  }t>        jC                  t        |d-         t        |d.   jE                  d/      xs d              E tF        jI                  d0tK        |      tK        |      tM        |
      |	xs d1|jO                                t8        S )2uZ   _get_router의 lock 내부 빌드 로직 — _init_lock를 이미 잡고 있어야 한다.r   r   
gemini-progemini-flashgemini-litegpt-minizgemini/)r   api_keyidz-key)
model_namelitellm_params
model_infor   drop_paramsTreasoning_effortopenai_reasoning_effortmediumopenai_reasoning_effort_auxlowopenai_reasoning_effort_judgehighgptzopenai/)r  r  	gpt-terragpt-lunagpt-nanogpt-4.1gpt-4.1-minigpt-highgrok_judge_modelr   grokzopenrouter/)r   r  api_baser  claude-opusclaude-fablez
anthropic/)r   r  r  Nzsimple-shuffle   )
model_listrouting_strategynum_retriestimeoutretry_after)r   r   r  r  r   zQLiteLLM Router initialized: %d deployments (%d Gemini keys, OpenAI=%s slot=%s/%d)u   없음)(r#   r   r   gemini_api_keygemini_text_modelgemini_flash_modelgemini_lite_model	enumeraterq   r   r   register_switch_hookr   active_slot_and_keyrG   r%   openai_modelr!   openrouter_api_keyopenrouter_base_urlanthropic_api_keyanthropic_judge_modelanthropic_fable_modelr   r  r
   llm_max_retriesllm_timeout_textr   r   r   _ALIAS_PHYSICALclear
setdefaultr    r7   r8   r3   r$   r   )r   r  gemini_keysgemini_modelsaliasmodel_idir9   r   openai_slot
openai_key_OPENAI_DROP_EFF_EFF_AUX
_EFF_JUDGE	alt_model	alt_alias_eff_grok_model_alias_mdl_litellm_ds                          r   r   r   /  s    )J $%K822../ 
x112	445	223	X001M )x,FAs#&xj1"#  $wd1#%67  - )" %$$%78 *==?K &t,
 #CH7BNh%P Q&H;UCLu)N O )#H=vF + 
 	"8#8#8"9:%  	
 	" ""K:""J9""J9	2&^R0""J
;+
&Iy$ '&yk2)# ## 	# +
D 8%7<BIIKK""{ &{m4#66$88  $

 	$ !!H::;X;;<
LFD
 $)$0'99 $(#	 	
& H),,))G ;w?H ""< !3r*:';'?'?'H'NB#O	Q  KK	 J[)4
+;x!7!7!9	 Nr   prompt_translationu   T2I 프롬프트 번역r  	image_sublabeldefaultcategoryscene_t2i_genu   T2I 이미지 생성zgemini-imagescene_t2i_validationu   이미지 검증r  prompt_sanitizeu   프롬프트 안전화angle_recommendu   앵글 추천fal_angle_applyu   fal.ai 앵글 적용zfal-aifinal_selectu   최종 선택t2i_translationu   T2I 편집 번역grounding_classifyu   고증 후보 분류analysisscene_detail_owned_judgeu   owned 객체 redraw 검사analysis_subscene_detail_owned_repairu   owned 객체 redraw 수정background_plate_orderu   야외 plate 생성 순서entity_extractu   요소 추출 (3턴)entity_styleu"   요소 추출 — 스타일+이름entity_detail_batchu   요소 추출 — 상세webbook_genu   웹북 패키지 생성	auxiliarystyle_rulesu   스타일 규칙 생성u   아웃룩 병합u   Location 외형 고정r   )outlook_mergelocation_consistency_PIPELINE_STEP_EXTENSIONSc                  B   ddl m}  i }| j                         D ]>  \  }}|j                  d|      |j                  dd      |j                  dd      d||<   @ t        j                         D ].  \  }}||v rt
        j                  d	|       !t        |      ||<   0 |S )
ud  STEP_MANIFEST + extension 을 합쳐 ``PIPELINE_STEPS`` view 를 build.

    manifest 의 ``default_model`` / ``provider`` / ``category`` / ``label`` 을
    꺼내 기존 PIPELINE_STEPS schema (``label`` / ``default`` / ``category``) 로
    변환. 동일 step 이 양쪽에 있으면 manifest 우선 (problems.md #5: manifest
    is single source).
    r   )STEP_MANIFESTrI  default_modelr   rK  rT  rH  u{   _PIPELINE_STEP_EXTENSIONS overlap with STEP_MANIFEST: %s — manifest wins (extension entry is dead and should be removed).)app.core.step_manifestrc  rk   r    ra  r7   r   r2   )rc  outsidr8   s       r   _build_pipeline_stepsrh  (  s     5%'C"((*	TXXgs+xx>Z8
C + /446	T#:NNQ
 :C 7 Jr   zGPT-6 Astra (medium)openai)r5  rI  providerr  zGPT-6 Astra (low)r  zGemini 3.8 Flashgeminir  zGemini 3.1 Pror   r   r  zGPT-4.1r  zGPT-4.1 Minir  zClaude Opus 5	anthropicr  zClaude Fable 5r  zGrok 4.6xai_UNKNOWN_STEP_WARNEDproject_configc                 @   |r=| |v r9||    j                  dt        j                  | i       j                  dd            S t        j                  |       }|3| t        vr+t        j                  |        t        j                  d|        |xs i j                  dd      S )uE  단계 + 프로젝트 설정 → LiteLLM Router 모델 별칭 반환.

    PIPELINE_STEPS (manifest+extension) 에 없는 step 은 ``gemini-pro`` 로 silent
    fallback. typo 등을 surface 하기 위해 process 당 1회 logger.warning emit
    (review I2). project_config 가 명시한 step 은 fallback 대상 아님.
    r   rJ  r   u   _resolve_model: unknown step '%s' — falling back to 'gemini-pro'. If intentional, register in STEP_MANIFEST or _PIPELINE_STEP_EXTENSIONS.)r    PIPELINE_STEPSrn  addr7   r   )r>   ro  r8   s      r   _resolve_modelrs  h  s     $.0d#''1C1CD"1M1Q1QR[]i1jkkd#D|$88  &V	

 JBI|44r   >   r  r  r  r  r  r  r   r  r   r  r   c                 V   | t         v r|j                  dd       t        |       }|-|j                  d      }||nt	        t        |      |      |d<   | t        v rMd|v rH|j                  d      }|j                  d      }||nt	        t        |      t        |            |d<   yyy)u   모델별로 전달 불가 파라미터를 in-place 제거.

    drop_params 글로벌/deployment 설정이 일관되게 적용되지 않는 버전이 있어 명시 제거.
    temperatureN
max_tokensmax_completion_tokens)_NO_TEMPERATURE_ALIASESrp   _provider_max_outputr    minr   _OPENAI_ALIASES)r   r   capwanthaves        r   r   r     s    
 ''

=$'
u
%C
zz,'&*lsCIs8K| LF$:zz,'zz12LDc#d)SY&? 	&'	 %;r   i@  MODEL_MAX_OUTPUT_TOKENSc                     | t         v r	t         |    S 	 ddl}t        j                  |       }|sy |j                  |      }|j                  d      }|rt        |      S dS # t        $ r Y yw xY w)u  그 alias 의 **물리 모델이 실제로 허용하는** 출력 상한.

    ★[2026-09-09] 왜 필요한가 — 전역 하나(`llm_max_output_tokens`)로 모든
     모델을 재던 판에서 두 방향으로 틀렸다:

        gpt-6-astra  최대 128,000 인데 **65,536 만** 요청했다 (절반을 버렸다)
        gpt-4.1      최대  32,768 인데  65,536 을 요청했다 (넘겨서 보냈다)

     그래서 모델 표에서 읽어 **모델마다 제 최대**를 쓴다. 손으로 적으면
     모델을 갈아 끼울 때 또 어긋난다 — 값의 주인은 provider 다.

    ★`MODEL_MAX_OUTPUT_TOKENS` 의 명시 값이 있으면 **그쪽이 이긴다.**
     grok 8,000 처럼 우리가 비용 때문에 일부러 좁힌 것이 있다.
    r   Nmax_output_tokens)r  r   r0  r    get_model_infor   r&   )r   r   depr8   rw   s        r   ry  ry    sz     ''&u--
!!%(%w%%c*HH()s1v$$ s   A! /A! A! !	A-,A-r0  >   r  r   r   r   HARM_CATEGORY_HARASSMENT
BLOCK_NONE)rK  	thresholdHARM_CATEGORY_HATE_SPEECHHARM_CATEGORY_SEXUALLY_EXPLICITHARM_CATEGORY_DANGEROUS_CONTENTc                 (    | t         v r
t        |d<   yy)ud   Gemini 모델 호출에 safety_settings BLOCK_NONE 강제 — 픽션 콘텐츠 분석 차단 회피.safety_settingsN)_GEMINI_ALIASES_GEMINI_SAFETY_SETTINGS_OFFr   s     r   _apply_gemini_safetyr    s    $? !  r   >   r  r  r  r  r  uniqueItemsnoder4   c           	          t        | t              r2| j                         D ci c]  \  }}||vr|t        ||       c}}S t        | t              r| D cg c]  }t        ||       c}S | S c c}}w c c}w )u   node 트리의 모든 중첩 dict 에서 keys 를 제거한 deep copy 반환.

    원본 node 는 mutate 하지 않는다 — dict/list 를 새로 재구성한다.
    )r1   r2   rk   _strip_schema_keysr   )r  r4   rv   rw   s       r   r  r    s    
 $ 


$1} !!T**$
 	

 $59:T"1d+T::K
 ;s   A/A5c                    t        | t              r| j                  d      }t        |t              r]|r[| j                  d      }t        |t              r%t	        |      t	        |j                               k7  ry| j                  d      duryt        d | j                         D              S t        | t              rt        d | D              S y)up  OpenAI strict 의 known object-contract 호환성 재귀 검사 (완전 판정 아님).

    검사 범위는 이번에 실측된 object 계약(required=전 property 키 +
    additionalProperties:false)에 한정 — strict 의 전체 keyword subset 을
    판정하지 않는다 (uniqueItems 는 별도 strip). properties={} 빈 object
    검사는 후속 hardening 항목 (Codex 비차단 권고 2026-07-10).

    strict=True 는 모든 object 노드에 대해 ①`required` 가 properties 의 전
    키를 포함하고 ②`additionalProperties: false` 명시를 요구한다. Gemini
    시절 저작 팩 스키마(optional 필드 관용)는 이 규칙을 어겨 BadRequestError
    ("'required' is required to be supplied and to be an array including
    every key in properties") 가 난다 — GPT-5.6 이관 2회차 E2E shot_extract
    30/30 실측 (2026-07-10).
    r0   requiredFadditionalPropertiesc              3   2   K   | ]  }t        |        y wrF   _is_openai_strict_compatiblerH   rw   s     r   rJ   z/_is_openai_strict_compatible.<locals>.<genexpr>*  s     JMq/2MrK   c              3   2   K   | ]  }t        |        y wrF   r  r  s     r   rJ   z/_is_openai_strict_compatible.<locals>.<genexpr>,  s     ADq/2DrK   T)r1   r2   r    r   setr4   allr5   )r  r;   reqs      r   r  r    s     $&eT"u((:&Cc4(CHEJJL8I,Ixx./u<JDKKMJJJ$ADAAAr   _STRICT_DOWNGRADE_LOGGEDdroppedc           	         t        | t              ri }| j                         D ]h  \  }}|dk(  rOt        |t              r?|r=t	        d |D              s+|j                  t        | j                  dd                   Zt        ||      ||<   j |S t        | t              r| D cg c]  }t        ||       c}S | S c c}w )u  `enum` 값이 문자열이 아닌 노드에서 `enum` 만 걷은 deep copy.

    ★Gemini `response_schema` 의 `enum` 은 **문자열 배열**이다. 정수 enum 을
     보내면 400 이 난다 (2026-08-28 실측, `gemini-3.1-pro-preview`):

        Invalid value at '…properties[0].value.enum[0]' (TYPE_STRING), 1

     정수 enum 을 주입하는 자리는 **셋**이다(`app/` 전 트리 AST 로 셌다):

     · `beat_shot_steps.py:226` — `beat_extract` 의 `scene_index`
     · `beat_shot_steps.py:529` — `shot_extract` 의 `scene_index`
     · `scene_camera_flow_step.py:240` — `scene_camera_flow` 의 `shot_index`

     ★셋째 자리가 더 고약하다 — 그 스텝은 예외를 **빈 flow 로 삼켜서**
      실패가 안 보이고 품질만 조용히 내려앉는다. 앞 둘은 스텝이 죽어
      주행이 멎으니 오히려 드러난다.

     08-24 주행에는 통했는데 이번엔 400 이라 **provider 쪽이 조인 것**으로
     본다(표본 하나라 단정은 아니다).

    ★왜 문자열로 바꾸지 않고 **걷나**: Gemini 의 enum 은 `type: STRING` 전용
     이라 정수를 문자열로 바꿔 보내면 모델이 문자열로 돌려줄 수 있고, 그러면
     `set(returned_indices) != set(expected_indices)` 대조가 통째로 어긋난다.
     enum 은 **안내**이고 계약은 뒤에서 따로 지킨다 — 반환 인덱스를 기대값과
     대조해 순서로 재매핑하고, 안 맞으면 재시도하고, 빠진 씬은 빈 beat 로
     채운다(`beat_shot_steps.py:248-280`). 안내 하나를 잃고 스텝을 살린다.

    `type` 은 그대로 둔다 — 정수를 달라는 요구는 남는다.
    enumc              3   <   K   | ]  }t        |t                y wrF   )r1   rG   )rH   xs     r   rJ   z)_drop_non_string_enums.<locals>.<genexpr>U  s     >Aq
1c 2As   type?)	r1   r2   rk   r   r  rq   rG   r    _drop_non_string_enums)r  r  rf  rv   rw   s        r   r  r  3  s    < $JJLDAqV
1d 3>A>>s488FC#89:+Aw7CF ! 
$<@ADq&q'2DAAK Bs   $B<_GEMINI_ENUM_DROP_LOGGEDc                    |j                  d      }t        |t              sy|j                  d      }t        |t              rd|vry| t        v rg }t	        |d   |      |d<   t        |j                  dd            }|r_|t        vrWt        j                  |       t        j                  d|dj                  t        t        |                  t        |             y| t        vryt        |d   t               |d<   |j                  d	      rdt#        |d         sUd
|d	<   t        |j                  dd            }|t$        vr,t$        j                  |       t        j                  d|       yyyy)u  OpenAI 계열 model 호출 시 response_format schema 의 OpenAI 비호환
    jsonschema keyword 를 제거 (FINDING 10 — provider-boundary fix).

    OpenAI strict structured output 은 `uniqueItems` 를 거부한다. Gemini 경로는
    허용하므로 정상 동작하지만 GPT fallback 경로에서 BadRequestError 가 난다.
    `kwargs["response_format"]["json_schema"]["schema"]` 를 deep-copy + strip 한
    새 객체로 교체한다 — caller 의 원본 response_schema 는 mutate 하지 않으므로
    `_validate_local_schema` 의 local 검증은 원본 schema(uniqueItems 포함)로
    그대로 수행된다. 비-OpenAI(Gemini 등) model 은 no-op.

    GPT-5.6 이관 (2026-07-10): strict 비호환 스키마(Gemini 팩 유래 — optional
    필드/additionalProperties 미명시)는 strict=False 로 강등한다. 스키마
    준수는 `_validate_local_schema` + call_structured retry 가 전 tier 에서
    이미 보증(Gemini 경로와 동일한 enforcement 모델). strict 호환 스키마
    (기존 gpt 스텝 팩)는 strict=True 그대로 — byte-identical. 팩 수정 없이
    provider boundary 에서 해소 (프롬프트 팩 덮어쓰기 금지 준수).
    response_formatNjson_schemar-   namer  u   response_format '%s': Gemini 는 문자열 enum 만 받는다 — %s 형 enum %d개 걷음. 계약은 호출부 검증·재시도가 지킨다 (process 당 1회 로그),strictFu   response_format '%s': OpenAI strict 비호환 스키마(Gemini 팩 유래) — strict=False 강등, 준수는 local validation+retry 가 보증 (process 당 1회 로그))r    r1   r2   r  r  rG   r  rr  r7   r8   rP   sortedr  r3   r{  r  _OPENAI_UNSUPPORTED_SCHEMA_KEYSr  r  )r   r   r  r  r  r  s         r   #_sanitize_response_format_for_modelr  b  sh   $ jj!23Oot,!%%m4Kk4(HK,G  6!7!,H;??63/0t#;;$((.KK,-1388F3w<<P3QG	 	O#.H>K x )E!*# %H;??63/0//$((.KK348 0	*# r   )rv  sinkresponser5  rv  c                T   | y|| d<   ||| d<   t        |dd      }|r|| d<   t        |dd      }|?dD ]:  }t        ||d      }|!t        |t              r|j                  |      }|6|| |<   < t        |dd      }t        |t              r|j                  d	      }	|	|	| d
<   yyy)uT  토큰·비용·물리 모델을 호출자에게 돌려준다 — **주면 채우고 안 주면 안 한다.**

    ★왜 필요한가 (2026-08-27, #92). `call_structured` 는 payload 만 돌려줘
     호출자가 **한 판정에 얼마를 썼는지 알 길이 없다.** 두 모델을 부르는
     판에서는 모델별 비용을 나눠 적어야 하는데, 그러려면 응답이 들고 온
     값을 그 자리에서 받아야 한다.

    ★**미보고를 0 으로 쓰지 않는다.** provider 가 usage 를 안 주면 키를
     비워 둔다 — 0 으로 채우면 「안 썼다」로 읽힌다. 내가 그 함정을 이미
     한 번 밟았다(`cost` 가 딕셔너리인데 스칼라로 읽어 0 이 나왔고 그것을
     「기록이 없다」로 보고했다).

    Args:
        sink: 채울 딕셔너리. `None` 이면 아무것도 안 한다(기존과 동일).
        response: litellm 응답 객체.
        alias: Router alias (`grok`·`gemini-pro` …).
        max_tokens: 그 요청에 실제로 실린 상한 — 잘림을 나중에 가르려면
            필요하다.
    Nr5  max_tokens_sentr   physical_modelr   )prompt_tokenscompletion_tokenstotal_tokens_hidden_paramsresponse_costestimated_cost_usdr   )
r  r  r5  rv  physicalr   r9   rw   hiddencosts
             r   _fill_usage_sinkr    s    . |DM",x$/H!)Hgt,EICsD)AyZt4IIcN}S	 J X/6F&$zz/*)-D%&   r   rs   r|   c                    | rt        |       ni }t        |j                  d      xs i       }t        |j                  d      xs g       }	 ddlm} t        |dd      rddlm}  ||      sd| }||vr|j                  |       ||d<   ||d<   |S # t        $ r }t        j                  d	|       Y d
}~Ed
}~ww xY w)u  Opik metadata에 fallback tag(sanitized/gpt_fallback)를 추가한 새 dict 반환.

    원본 dict 변경 없음. opik.tags가 없으면 새로 만든다.

    ★v2 에서는 `status:` 축을 붙인다 (2026-08-24 Codex 재리뷰). 이 자리가
    `_build_opik_metadata` 의 축 정규화 **뒤**라, 접두사 없이 붙이면 앞에서
    판 축이 여기서 다시 섞인다 — 그리고 그 span 은 안전 sanitize·GPT
    fallback 이라 정작 훑을 값이 큰 쪽이다. 설정 OFF 면 맨 이름 그대로다.
    r`   ra   r   r   rb   F)re   zstatus:u6   _add_fallback_tag v2 축 부착 실패 (non-fatal): %sN)r2   r    r   r#   r   r%   rn   re   r&   r7   rr   rq   )rs   r|   new_metarZ   ra   r   re   rT   s           r   _add_fallback_tagr    s     "*tH~rHX\\&)/R0I	f%+,D	T,84e<>s#uo $CIf HVO  TMsSSTs   &B 	B=B88B=T)enable_fallbackvalidate_response
usage_sinkr  r  system_promptuser_prompt
str | listru  r  r  r  r  r  c	                   
 ddl m}mm}m} 	 ddt
        dddt        t           dt
        dt        t
        t        f   f
 f	d	}d
t        t
        t        f   dt
        dt        t
        t        f   f 
fd}	  ||||      } ||d      S # t        $ r,}|	r ||      s t        j                  d |       Y d}~nd}~ww xY w ||      }||z   }	  ||||d      } ||d      S # t        $ r!}t        j                  d |       Y d}~nd}~ww xY w|rt        |      ni }ddi| <    ||||d      S )u`  Structured JSON output 호출 — LiteLLM Router 경유.

    ★``timeout`` — 이 호출만의 대기 상한(초). ``None`` 이면 Router 가 지어질
     때 박힌 ``settings.llm_timeout_text`` 를 그대로 쓴다(기존 호출 불변).
     **전역을 올리지 않는다** — 긴 대본 한 자리 때문에 모든 글 스텝이
     오래 기다리게 만들면 진짜로 걸린 호출도 그만큼 늦게 드러난다
     (Codex 2026-09-09).

    모든 provider에 대해 response_format으로 JSON schema 강제.
    LiteLLM이 provider별 변환 자동 처리.
    user_prompt: str 또는 multimodal content list (PDF/이미지 포함 시).

    3-tier fallback (enable_fallback=True 시 자동, default):
      Tier 1: 기본 모델 (gemini-pro 등 step 기본).
      Tier 2: sanitize_for_safety + SAFETY_SYSTEM_SUFFIX prepend.
      Tier 3: GPT 강제 (project_config[step] = {"model": "gpt"}).

    enable_fallback=False (legacy 호환): 1차 호출만 수행, 실패 시 즉시 raise.

    `validate_response` (P2-3): Tier 1/2 응답이 valid JSON이지만 의미상 빈 결과
    (예: 빈 list)일 때 caller가 fallback을 강제 트리거할 수 있는 callback.
    callback이 False 반환 → `EmptySemanticResponseError` raise → safety 분류 →
    Tier 2/3 진행. None이면 schema 검증만 통과해도 즉시 반환 (기본 동작).
    이 callback 은 Tier 3에는 적용 안 함 (마지막 시도 보호).

    Local jsonschema 검증 (problems.md #13): ``_do_call`` 안에서 ``jsonschema.
    validate`` 가 **모든 tier 에서** 동작하여 provider strict mode 의 enforcement
    약화를 보완한다. ``validate_response`` callback (의미적 빈 결과) 와 별개의
    레이어 — Tier 3 도 schema 위반 시 ``SchemaValidationError`` 가 caller 까지
    전파된다. ENV ``LLM_LOCAL_SCHEMA_VALIDATE=false`` 로 즉시 disable 가능.

    fallback 트리거 분류 (`is_safety_related_error`):
      - 콘텐츠 안전/모더레이션 신호 (PROHIBITED/content_filter/empty/...): 진행
      - rate-limit/timeout/auth 등 명시적 transient: 즉시 raise (비용 보호)
    r   )SAFETY_SYSTEM_SUFFIXEmptySemanticResponseErroris_safety_related_errorsanitize_for_safetysys_puser_pr  cfg
suffix_tagr   c                   	 t               }t        |      }t              }|rt        ||      n|}|d| dd|dgdddd|d}d	d
lm}	 n|	j                  |d<   t              |d<   t              |d<   t        ||       t        ||       t        ||       t        |||      }
t        |
||j                  d             |
j                  d	   j                   j"                  }|s,t%        d d| dt'        |
j                  d	   dd             t)        t+        j,                  |            }t/        |       |S )Nsystemrolecontentuserr  Tr  r-   r  r  r  )r   messagesr  ru  rs   r   r   rv  r  r  )r5  rv  z%LLM returned empty response for step=, model=z, finish_reason=finish_reasonr>   r?   )r   rs  r}   r  r#   r   llm_max_output_tokensr   floatr   r  r  r   r  r    choicesrR   r  r   r%   r<   jsonloadsrV   )r  r  r  r  r   r   base_metadatars   r   r   r  r  r,   rv  r  r^   r=   r?   r>   ru  r  r  s                r   _do_callz!call_structured.<locals>._do_call(  s    &'tS),T=ACM$]J?S` !e4F3
 &'-"   ' "
" 	--7-CzIgIg| "$'$4F=! %gF9"5&1+E6:UF+wv6XU$*JJ|$<	> ""1%--55 #7vXeW M!8++A.FIKL L
 (

7(;_M_4[	
 r   result
tier_labelc                 :     |       s d d| d      | S )uU   validate_response callback 적용. False 반환 시 EmptySemanticResponseError raise.z"validate_response failed for step=z tier=u    — semantic empty resultr   )r  r  r  r>   r  s     r   _validate_or_raisez+call_structured.<locals>._validate_or_raisei  s=    (1B61J,4TF& M( )  r   tier1z>call_structured[%s] Tier 1 failed (%s), trying sanitized inputN	sanitizedr  tier2zEcall_structured[%s] Tier 2 sanitized failed (%s), trying GPT fallbackr   r  gpt_fallbackr   )rL   r  r  r  r  rG   r	   r   r   r&   r7   r   r2   )r>   r  r  r=   ro  r?   r^   ru  rv  r  r  r  r  r  r  r  r  r  r  r  exc_t1sanitized_usersafe_systemexc_t2
gpt_configr  s   `  ` ```` ````           @r   call_structuredr    sn   h  	??? d^? 	?
 
c3h? ?B4S> s tCQTH~ 	
-nE!&'22 
&=f&EL&	
 	

 )5N"66K
+~~R]^!&'22 
S&	
 	

 *8n%RJ'JtKWWs0   B 	C'"CC$C9 9	D#DD#)r  c                d    ddl m}m}m}	 ddt        dt        dt
        t           dt        dt        f
 fd	}
	  |
|||      }|r|S |syt        j                  d
         |	|      }||z   }	  |
|||d      }|r|S t        j                  d        |rt        |      ni }ddi| <    |
|||d      }|st        d  d      |S # t        $ r,}|r ||      s t        j                  d |       Y d}~d}~ww xY w# t        $ r!}t        j                  d |       Y d}~d}~ww xY w)u	  Free-text 호출 — LiteLLM Router 경유.

    3-tier fallback (call_structured와 동일 정책)을 자동 적용한다.

    `enable_fallback=False` (legacy 호환):
      - Tier 1만 수행. 빈 응답은 `""` 반환 (raise 안 함) — pre-fallback 시기 동작 보존.
      - 그 외 예외는 그대로 raise.

    `enable_fallback=True` (default):
      - 빈 응답을 RuntimeError로 변환 → safety 분류 → Tier 2/3 진행.
      - rate-limit/timeout 등 transient는 즉시 raise (비용 보호).
    r   )r  r  r  r   r  r  r  r  r   c                 @   t               }t        |      }t              }|rt        ||      n|}ddlm} |d| dd|dg|j                  |d}	t        ||	       t        ||	       t        |||	      }
|
j                  d   j                  j                  xs dS )u_   완료된 응답 content 반환. 빈 응답이라도 raise 없이 그대로 (caller가 처리).r   r   r  r  r  )r   r  ru  rv  rs   r   )r   rs  r}   r  r#   r   r  r   r  r   r  rR   r  )r  r  r  r  r   r   r  rs   r   text_kwargsr  r^   r>   ru  s              r   r  zcall_text.<locals>._do_call  s    %'tS),T=ACM$]J?S`,!e4F3 '"88 	'
 	#5+6UK0w{;"**228b8r   z;call_text[%s] Tier 1 returned empty, trying sanitized inputz8call_text[%s] Tier 1 raised (%s), trying sanitized inputNr  r  z8call_text[%s] Tier 2 returned empty, trying GPT fallbackz?call_text[%s] Tier 2 sanitized failed (%s), trying GPT fallbackr   r  r  z*LLM returned empty text response for step=z after all 3 tiersr  )rL   r  r  r  rG   r	   r   r7   r   r&   r2   r   )r>   r  r  ro  r^   ru  r  r  r  r  r  r  r  r  r  r  r  s   `   ``           r   	call_textr    so   , 9 9S 9x~ 93 9X[ 90
=+~F NI	
 )5N"66K
;S^_ NF	
 *8n%RJ'Jt{NJ>ZG8>PQ
 	
 N]  
&=f&EF&	
 	

0  
M&	
 	

s/   
C 2D 	D"C==D	D/D**D/r  c          	          ddl m}m}	m}
 ddt        t
        t        t        f      dt        t
           dt        dt        f fd}	  |||      S # t        $ r,}|r |	|      s t        j                  d	 |       Y d
}~nd
}~ww xY w |
|      }|rkt        |d   t              rX|d   j                  d      dk(  rAt        |d         }|j                  dd      }t        |t              r||z   |d<   |g|dd
 z   }	  |||d      S # t        $ r!}t        j                  d |       Y d
}~nd
}~ww xY w|rt        |      ni }ddi| <    |||d      S )ub  멀티턴 대화 호출 — entity_extractor Turn 0→1 등에 사용.

    messages: [{"role": "system", "content": ...}, {"role": "user", "content": ...}, ...]
    response_schema: 있으면 structured, 없으면 free text.

    3-tier fallback (call_structured와 동일 정책)을 자동 적용한다.
    Tier 2/3에서 system 메시지에 SAFETY_SYSTEM_SUFFIX append +
    user/assistant content 일괄 sanitize.

    Tier 1 예외 분류 (`is_safety_related_error`):
      - 콘텐츠 안전 신호: Tier 2/3 진행
      - 명시적 transient (timeout/rate-limit 등): 즉시 raise (비용 보호)
    r   )r  r  sanitize_messagesr   msgsr  r  r   c                    t               }t        |      }t              }|rt        ||      n|}|| |d}t	        ||       t        ||       rdddd|d<   t        ||       t        |||      }|j                  d   j                  j                  xs d}	rC|	st        d	 d
|       t        t        j                  |	            }
t        |
       |
S |	st        d d
|       |	S )N)r   r  ru  rs   r  Tr  r  r  r   r   z/LLM returned empty multiturn response for step=r  r  z+LLM returned empty multiturn text for step=)r   rs  r}   r  r   r  r  r   r  rR   r  r   r<   r  r  rV   )r  r  r  r   r   r  rs   r   r  r  r,   r^   r=   r?   r>   ru  s              r   r  z call_multiturn.<locals>._do_call  s:   %'tS),T=ACM$]J?S` & 	"
 	#5&1UF+%'-" )F$% 0v>wv6""1%--55;"EdV8TYSZ[  ,

7#_6G"t N=dV8E7S  r   z=call_multiturn[%s] Tier 1 failed (%s), trying sanitized inputNr  r  r  r/   r  r  zDcall_multiturn[%s] Tier 2 sanitized failed (%s), trying GPT fallbackr   r  r  r  )rL   r  r  r  r   r   rG   r	   r   r&   r7   r   r1   r2   r    )r>   r  r=   ro  r^   r?   ru  r  r  r  r  r  r  safe_messagesfirstsys_contentr  r  s   ` ` ```           r   call_multiturnr     s|   0 ,tDcN+ ,(4. ,c ,[^ , ,\
.11 
&=f&EK&	
 	

 &h/MM!$4d;a@P@T@TU[@\`h@h]1%&ii	2.k3'*-AAE)-"33
~+NN 
R&	
 	

 *8n%RJ'JtM:.IIs0   A 	B!"BB
D 	D;D66D;rF   )r   N)r   r   )Nr  N皙?N)NNr  )NNNr  r  )Tr   r  loggingr   	threadingpathlibr   typingr   r   r   r   r   r	   rM   r   r
   	getLoggerr   r7   r   r   r$   r(   	frozensetr6   rG   r<   rV   localrY   r[   r]   r}   r   r   RLockr   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   ra  rh  rq  AVAILABLE_MODELSr  rn  rs  rx  r   r  ry  r0  r  r  r  r{  r  r  r  r  r  r  r  r  r  r  r  r  r   r   r   r   <module>r     s&	     	   B B   			8	$| 4 2   DE 3 S#X 3 ("#s(^"#s(^" 	"
 " 
"J  	!#8D> #T #
5x~ 5Ds D8D> DT DP 59LC L&tCy1L=AL"   Y__
 = 
  tCH~ @;X|!< !&	  Z ( &*(>
" )49 &T) T# TtCH~ TnP6Q Qs Q$tv tP.8&?jkvw.8 &<^juv.8 &8Ujuv	.8
 &>PUmxy.8 oEitu.8 &<Hitu.8 oEitu.8 &9Jitu.8 &<Ujtu.8. .:Sab1.8:  .:Sab=.8F .:S^_I.8N &<ekuvO.8P &JW\q{|Q.8R &@QVnxyS.8T &?PUmxyU.8V &?QVnxyW.8X '9Ujtu&>\jtu[.8 4T#s(^ 34 .btCc3h$78 > '( '=8T':8T':8T'98T':8T'78T'98T'98Ty8T~8T;W'7;W z5Q/ 6 "% c#h &5 5htn 5 5: $ %  Ac A4S> Ad AP ,24. c3h 8  > #%c3h $
 UV ,N,N2N2N	 @ @T#s(^ @ @  ) * #,]O"< S 	 c  s t : &)U #c( *) )tCy )S )X &)U #c( *8s 8DcN 8t 8~ #'-.
4S>
"-..1-.-.-. 
-.`S#X S T#s(^ F &*!$( $cX !DH+/!%#cX
cXcX cX #s(^	cX
 TNcX cX D>cX cX cX cX  $sCx.)94)? @AcX c3h(cX #cX e_cX  
#s(^!cXT &*$(d !d
dd d TN	d
 D>d d d 	dT 15%)$(!iJ !iJ
iJ4S>"iJ d38n-iJ TN	iJ
 D>iJ iJ iJ iJ 	iJr   