File size: 30,672 Bytes
a5784e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
"""
Function Calling Orchestrator

Central coordinator that routes function calling between native and emulated modes.
Integrates SchemaConverter, ResponseFormatter, and browser automation into the request flow.

Implements Phase 3 of ADR-001: Native Function Calling Architecture.
Includes caching to skip redundant UI operations for subsequent requests with same tools.
"""

import asyncio
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

from browser_utils.page_controller import PageController
from logging_utils.fc_debug import FCModule, get_fc_logger

# FC debug logger for orchestrator-level events
fc_logger = get_fc_logger()
# Import directly from the module file to avoid circular imports through __init__.py
# fmt: off
from api_utils.utils_ext.function_calling import (  # noqa: E501
    FunctionCallingConfig,
    FunctionCallingMode,
    ParsedFunctionCall,
    ResponseFormatter,
    SchemaConversionError,
    SchemaConverter,
    get_finish_reason,
)
from api_utils.utils_ext.function_calling_cache import FunctionCallingCache
from config.settings import (
    FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS,
    FUNCTION_CALLING_DEBUG,
    FUNCTION_CALLING_MODE,
)
from models import ClientDisconnectedError

# fmt: on


class NativeFunctionCallingError(Exception):
    """Raised when native function calling fails and fallback may be needed."""

    pass


@dataclass
class FunctionCallingState:
    """Tracks the state of function calling for a request.

    Attributes:
        mode: The effective mode being used for this request.
        native_enabled: Whether native mode was successfully enabled.
        tools_configured: Whether tools were configured in native mode.
        fallback_used: Whether fallback to emulated mode was used.
        error_message: Any error message from native mode attempts.
        tools_digest: Digest of tool definitions for caching.
        cache_hit: Whether cache was used to skip UI operations.
    """

    mode: FunctionCallingMode
    native_enabled: bool = False
    tools_configured: bool = False
    fallback_used: bool = False
    error_message: Optional[str] = None
    tools_digest: Optional[str] = None
    cache_hit: bool = False


class FunctionCallingOrchestrator:
    """
    Central orchestrator for function calling that handles mode selection,
    tool configuration, and response processing.

    Usage:
        orchestrator = FunctionCallingOrchestrator()

        # Before sending prompt
        state = await orchestrator.prepare_request(
            tools=request.tools,
            tool_choice=request.tool_choice,
            page_controller=page_controller,
            check_client_disconnected=check_fn,
            req_id=req_id,
        )

        # After receiving response (for non-streaming)
        processed = await orchestrator.process_response(
            raw_content=response_content,
            functions=function_data,
            state=state,
        )
    """

    def __init__(self, logger: Optional[logging.Logger] = None):
        """Initialize the orchestrator.

        Args:
            logger: Optional logger instance. If None, uses default logger.
        """
        self.logger = logger or logging.getLogger("AIStudioProxyServer")
        self._config = FunctionCallingConfig.from_settings()
        self._schema_converter = SchemaConverter()
        self._response_formatter = ResponseFormatter()
        self._cache = FunctionCallingCache.get_instance(self.logger)

    @property
    def config(self) -> FunctionCallingConfig:
        """Get the current function calling configuration."""
        return self._config

    @property
    def response_formatter(self) -> ResponseFormatter:
        """Get the response formatter instance."""
        return self._response_formatter

    @property
    def cache(self) -> FunctionCallingCache:
        """Get the function calling cache instance."""
        return self._cache

    async def _ensure_fc_disabled_when_no_tools(
        self,
        page_controller: PageController,
        check_client_disconnected: Callable,
        req_id: str,
    ) -> None:
        """Ensure function calling toggle is disabled when no tools are provided.

        This handles the edge case where a previous request enabled FC,
        but the current request (e.g., XML-based tools) doesn't use OpenAI-format tools.
        We need to disable the toggle to prevent interference.

        Args:
            page_controller: PageController instance for browser automation.
            check_client_disconnected: Callback to check client connection.
            req_id: Request ID for logging.
        """
        # Only check/disable if we're in native or auto mode
        # In emulated mode, the toggle should never have been enabled by us
        if self._config.mode == FunctionCallingMode.EMULATED:
            return

        try:
            # Check if FC toggle is currently enabled
            is_enabled = await page_controller.is_function_calling_enabled(
                check_client_disconnected, use_cache=True
            )

            if is_enabled:
                if FUNCTION_CALLING_DEBUG:
                    self.logger.info(
                        f"[{req_id}] [FC] No tools in request but FC toggle is enabled - disabling for clean state"
                    )
                start_time = time.perf_counter()

                success = await page_controller.disable_function_calling(
                    check_client_disconnected
                )

                elapsed = time.perf_counter() - start_time

                if success:
                    # Invalidate cache since state changed
                    self._cache.invalidate(reason="no_tools_cleanup", req_id=req_id)
                    if FUNCTION_CALLING_DEBUG:
                        self.logger.info(
                            f"[{req_id}] [FC:Perf] FC toggle disabled in {elapsed:.2f}s"
                        )
                else:
                    if FUNCTION_CALLING_DEBUG:
                        self.logger.warning(
                            f"[{req_id}] [FC] Failed to disable FC toggle - may affect response"
                        )

        except ClientDisconnectedError:
            # Client gone, nothing to do
            pass
        except asyncio.CancelledError:
            # Cancelled, skip cleanup
            pass
        except Exception as e:
            # Non-fatal error - log and continue
            # The request can still proceed, just with FC potentially enabled
            if FUNCTION_CALLING_DEBUG:
                self.logger.warning(
                    f"[{req_id}] [FC] Error checking/disabling FC toggle: {e}"
                )

    def should_use_native_mode(
        self,
        tools: Optional[List[Dict[str, Any]]],
        tool_choice: Optional[Union[str, Dict[str, Any]]],
    ) -> bool:
        """Determine if native mode should be attempted for this request.

        Args:
            tools: List of tool definitions from the request.
            tool_choice: Tool choice parameter from the request.

        Returns:
            True if native mode should be attempted, False otherwise.
        """
        # No tools = no need for native mode
        if not tools or len(tools) == 0:
            return False

        # Check mode setting
        mode = self._config.mode

        # Emulated mode always uses text injection
        if mode == FunctionCallingMode.EMULATED:
            if self._config.debug:
                self.logger.debug("FC: Mode is EMULATED, using prompt injection")
            return False

        # Native and auto modes should try native
        if mode in (FunctionCallingMode.NATIVE, FunctionCallingMode.AUTO):
            if self._config.debug:
                self.logger.debug(
                    f"FC: Mode is {mode.value}, attempting native UI automation"
                )
            return True

        if self._config.debug:
            self.logger.debug(f"FC: Mode {mode} unknown, defaulting to False")
        return False

    def get_effective_mode(
        self,
        tools: Optional[List[Dict[str, Any]]],
    ) -> FunctionCallingMode:
        """Get the effective function calling mode for a request.

        Args:
            tools: List of tool definitions from the request.

        Returns:
            The effective FunctionCallingMode to use.
        """
        if not tools or len(tools) == 0:
            return FunctionCallingMode.EMULATED

        return self._config.mode

    async def prepare_request(
        self,
        tools: Optional[List[Dict[str, Any]]],
        tool_choice: Optional[Union[str, Dict[str, Any]]],
        page_controller: PageController,
        check_client_disconnected: Callable,
        req_id: str,
        model_name: Optional[str] = None,
    ) -> FunctionCallingState:
        """Prepare a request for function calling based on the configured mode.

        This method:
        1. Computes tool digest and checks cache for existing state
        2. If cache valid: skips UI automation (cache hit)
        3. If cache miss: converts tools and configures browser UI
        4. Updates cache on success
        5. Handles fallback if native mode fails and fallback is enabled
        6. If no tools provided but FC was previously enabled, disables it

        Args:
            tools: List of OpenAI-format tool definitions.
            tool_choice: Tool choice parameter (auto, none, required, or specific).
            page_controller: PageController instance for browser automation.
            check_client_disconnected: Callback to check client connection.
            req_id: Request ID for logging.
            model_name: Optional model name for cache validation.

        Returns:
            FunctionCallingState with the configuration result.
        """
        total_start = time.perf_counter()
        state = FunctionCallingState(mode=self.get_effective_mode(tools))

        # No tools provided - ensure FC toggle is disabled if it was previously enabled
        if not tools or len(tools) == 0:
            await self._ensure_fc_disabled_when_no_tools(
                page_controller=page_controller,
                check_client_disconnected=check_client_disconnected,
                req_id=req_id,
            )
            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC] No tools in request, FC setup skipped/disabled"
                )
            return state

        if state.mode == FunctionCallingMode.EMULATED:
            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC] Using emulated mode, skipping native FC setup"
                )
            if FUNCTION_CALLING_DEBUG:
                fc_logger.log_mode_selection(req_id, "emulated", "configured mode")
            return state

        # Native or Auto mode - compute digest and check cache
        tools_digest = self._cache.compute_tools_digest(tools)
        state.tools_digest = tools_digest

        # Check cache first
        if self._cache.is_cache_valid(tools_digest, model_name, req_id=req_id):
            cached_state = self._cache.get_cached_state()
            if (
                cached_state
                and cached_state.declarations_set
                and cached_state.toggle_enabled
            ):
                # Cache HIT - but UI toggle may have been reset by new_chat
                # Verify and re-enable toggle if needed before trusting cache
                try:
                    # Check actual UI toggle state (bypass instance cache)
                    toggle_enabled = await page_controller.is_function_calling_enabled(
                        check_client_disconnected, use_cache=False
                    )
                    if not toggle_enabled:
                        if FUNCTION_CALLING_DEBUG:
                            self.logger.warning(
                                f"[{req_id}] [FC:Cache] HIT but UI toggle disabled - re-enabling"
                            )
                        enable_success = await page_controller.enable_function_calling(
                            check_client_disconnected
                        )
                        if not enable_success:
                            if FUNCTION_CALLING_DEBUG:
                                self.logger.warning(
                                    f"[{req_id}] [FC:Cache] Failed to re-enable toggle, "
                                    "falling through to full setup"
                                )
                            # Fall through to full native configuration
                        else:
                            elapsed = time.perf_counter() - total_start
                            if FUNCTION_CALLING_DEBUG:
                                self.logger.info(
                                    f"[{req_id}] [FC:Cache] HIT - toggle re-enabled "
                                    f"(digest={tools_digest[:8]}..., checked in {elapsed:.3f}s)"
                                )
                            state.native_enabled = True
                            state.tools_configured = True
                            state.cache_hit = True
                            return state
                    else:
                        elapsed = time.perf_counter() - total_start
                        if FUNCTION_CALLING_DEBUG:
                            self.logger.info(
                                f"[{req_id}] [FC:Cache] HIT - skipping native FC setup "
                                f"(digest={tools_digest[:8]}..., checked in {elapsed:.3f}s)"
                            )
                        state.native_enabled = True
                        state.tools_configured = True
                        state.cache_hit = True
                        return state
                except ClientDisconnectedError:
                    raise
                except Exception as e:
                    if FUNCTION_CALLING_DEBUG:
                        self.logger.warning(
                            f"[{req_id}] [FC:Cache] Toggle verification failed: {e}, "
                            "falling through to full setup"
                        )
                    # Fall through to full native configuration

        # Cache miss - proceed with native configuration
        if FUNCTION_CALLING_DEBUG:
            self.logger.info(
                f"[{req_id}] [FC] Configuring native function calling with {len(tools)} tool(s) "
                f"(digest={tools_digest[:8]}...)"
            )
        if FUNCTION_CALLING_DEBUG:
            fc_logger.log_mode_selection(
                req_id, "native", f"cache_miss, {len(tools)} tools"
            )

        # Log tool choice if specific
        if tool_choice:
            if isinstance(tool_choice, dict):
                forced_fn = tool_choice.get("function", {}).get(
                    "name"
                ) or tool_choice.get("name")
                if forced_fn:
                    if FUNCTION_CALLING_DEBUG:
                        self.logger.info(
                            f"[{req_id}] [FC] Tool choice: FORCING specific tool '{forced_fn}'"
                        )
            elif isinstance(tool_choice, str) and tool_choice.lower() not in (
                "auto",
                "none",
                "required",
            ):
                if FUNCTION_CALLING_DEBUG:
                    self.logger.info(
                        f"[{req_id}] [FC] Tool choice: FORCING specific tool '{tool_choice}'"
                    )

        try:
            # Convert OpenAI tools to Gemini format
            convert_start = time.perf_counter()
            gemini_declarations = self._schema_converter.convert_tools(tools)
            convert_elapsed = time.perf_counter() - convert_start

            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC:Perf] Converted {len(tools)} tools to Gemini format "
                    f"in {convert_elapsed:.3f}s"
                )
            if FUNCTION_CALLING_DEBUG:
                fc_logger.log_schema_conversion(
                    req_id, tool_count=len(tools), elapsed_ms=convert_elapsed * 1000
                )

            # Retry loop for UI automation
            last_error: Optional[Exception] = None
            for attempt in range(1, self._config.native_retry_count + 1):
                try:
                    if attempt > 1:
                        if FUNCTION_CALLING_DEBUG:
                            self.logger.warning(
                                f"[{req_id}] [FC:UI] Retry attempt {attempt}/{self._config.native_retry_count}"
                            )

                    check_client_disconnected(f"FC prepare attempt {attempt}")

                    # Check if function calling is available
                    fc_available = await page_controller.is_function_calling_available(
                        check_client_disconnected
                    )

                    if not fc_available:
                        raise NativeFunctionCallingError(
                            "Function calling UI not available for this model"
                        )

                    # Set function declarations via UI (with caching support)
                    success = await page_controller.set_function_declarations(
                        gemini_declarations,
                        check_client_disconnected,
                        tools_digest=tools_digest,
                        model_name=model_name,
                        tools=tools,
                    )

                    if success:
                        state.native_enabled = True
                        state.tools_configured = True
                        total_elapsed = time.perf_counter() - total_start
                        if FUNCTION_CALLING_DEBUG:
                            self.logger.info(
                                f"[{req_id}] [FC:Perf] Native function calling configured "
                                f"in {total_elapsed:.2f}s"
                            )
                        if FUNCTION_CALLING_DEBUG:
                            fc_logger.info(
                                FCModule.ORCHESTRATOR,
                                f"Native FC configured successfully in {total_elapsed:.2f}s",
                                req_id=req_id,
                            )
                        return state
                    else:
                        raise NativeFunctionCallingError(
                            "Failed to set function declarations in UI"
                        )

                except ClientDisconnectedError:
                    raise
                except asyncio.CancelledError:
                    raise
                except Exception as e:
                    last_error = e
                    if FUNCTION_CALLING_DEBUG:
                        self.logger.warning(
                            f"[{req_id}] [FC] Native FC attempt {attempt}/{self._config.native_retry_count} failed: {e}"
                        )
                    if attempt < self._config.native_retry_count:
                        await asyncio.sleep(0.5)

            # All retries failed
            raise NativeFunctionCallingError(
                f"Native function calling failed after {self._config.native_retry_count} attempts: {last_error}"
            )

        except SchemaConversionError as e:
            state.error_message = f"Schema conversion error: {e}"
            if FUNCTION_CALLING_DEBUG:
                self.logger.error(f"[{req_id}] [FC] {state.error_message}")

            # Schema errors are not recoverable - don't fallback
            if state.mode == FunctionCallingMode.NATIVE:
                raise

            # Auto mode with schema error - fall through to emulated
            state.fallback_used = True
            state.mode = FunctionCallingMode.EMULATED
            if FUNCTION_CALLING_DEBUG:
                self.logger.warning(
                    f"[{req_id}] [FC] Falling back to emulated mode due to schema error"
                )
            if FUNCTION_CALLING_DEBUG:
                fc_logger.log_mode_selection(
                    req_id, "emulated", "fallback_schema_error"
                )
            return state

        except NativeFunctionCallingError as e:
            state.error_message = str(e)
            if FUNCTION_CALLING_DEBUG:
                self.logger.warning(
                    f"[{req_id}] [FC] Native function calling failed: {e}"
                )

            # Check if fallback is allowed
            if state.mode == FunctionCallingMode.AUTO and self._config.native_fallback:
                state.fallback_used = True
                state.mode = FunctionCallingMode.EMULATED
                if FUNCTION_CALLING_DEBUG:
                    self.logger.info(
                        f"[{req_id}] [FC] Falling back to emulated mode for function calling"
                    )
                if FUNCTION_CALLING_DEBUG:
                    fc_logger.log_mode_selection(
                        req_id, "emulated", "fallback_native_error"
                    )
                return state
            elif state.mode == FunctionCallingMode.NATIVE:
                # Native mode with no fallback - raise error
                raise

        except ClientDisconnectedError:
            raise
        except asyncio.CancelledError:
            raise
        except Exception as e:
            state.error_message = f"Unexpected error: {e}"
            if FUNCTION_CALLING_DEBUG:
                self.logger.error(
                    f"[{req_id}] [FC] Unexpected error in FC prepare: {e}"
                )

            if state.mode == FunctionCallingMode.AUTO and self._config.native_fallback:
                state.fallback_used = True
                state.mode = FunctionCallingMode.EMULATED
                return state
            elif state.mode == FunctionCallingMode.NATIVE:
                raise

        return state

    async def cleanup_after_request(
        self,
        state: FunctionCallingState,
        page_controller: PageController,
        check_client_disconnected: Callable,
        req_id: str,
        preserve_cache: bool = False,
    ) -> None:
        """Clean up function calling state after a request completes.

        If configured to clear between requests and native mode was used,
        this will clear the function declarations from the UI.

        Args:
            state: The function calling state from prepare_request.
            page_controller: PageController instance for browser automation.
            check_client_disconnected: Callback to check client connection.
            req_id: Request ID for logging.
            preserve_cache: If True, don't invalidate cache (for same-tool sequences).
        """
        if not FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS:
            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC] Skipping cleanup (CLEAR_BETWEEN_REQUESTS=False)"
                )
            return

        if not state.tools_configured:
            return

        # If this was a cache hit, we didn't actually change anything
        if state.cache_hit:
            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC] Skipping cleanup (cache hit, no UI changes made)"
                )
            return

        try:
            start_time = time.perf_counter()
            # Pass invalidate_cache=not preserve_cache to control cache behavior
            await page_controller.clear_function_declarations(
                check_client_disconnected,
                invalidate_cache=not preserve_cache,
            )
            elapsed = time.perf_counter() - start_time

            if self._config.debug:
                self.logger.debug(
                    f"[{req_id}] [FC:Perf] Cleared function declarations in {elapsed:.2f}s"
                )
        except ClientDisconnectedError:
            pass  # Client gone, nothing to clean up
        except asyncio.CancelledError:
            pass  # Cancelled, skip cleanup
        except Exception as e:
            if FUNCTION_CALLING_DEBUG:
                self.logger.warning(
                    f"[{req_id}] [FC] Failed to clear function declarations: {e}"
                )

    def format_function_calls_for_response(
        self,
        functions: List[Dict[str, Any]],
        content: Optional[str] = None,
    ) -> Tuple[Dict[str, Any], str]:
        """Format function call data from AI Studio into OpenAI response format.

        Args:
            functions: List of function call data from AI Studio.
                       Each item should have 'name' and 'params' keys.
            content: Optional text content to include in the response.

        Returns:
            Tuple of (message_dict, finish_reason).
        """
        if not functions:
            return {"role": "assistant", "content": content or ""}, "stop"

        parsed_calls: List[ParsedFunctionCall] = []
        for func_data in functions:
            if isinstance(func_data, dict):
                name = func_data.get("name", "")
                params = func_data.get("params", {})
                if name:
                    parsed_calls.append(ParsedFunctionCall(name=name, arguments=params))

        if not parsed_calls:
            return {"role": "assistant", "content": content or ""}, "stop"

        message = self._response_formatter.format_non_streaming_response(
            parsed_calls, content=None
        )
        finish_reason = get_finish_reason(True)

        return message, finish_reason

    def format_streaming_tool_calls(
        self,
        functions: List[Dict[str, Any]],
        chunk_size: int = 50,
    ) -> List[Dict[str, Any]]:
        """Format function calls for streaming response.

        Generates all the delta chunks needed to stream function calls.

        Args:
            functions: List of function call data.
            chunk_size: Size of each arguments chunk.

        Returns:
            List of delta objects for streaming.
        """
        if not functions:
            return []

        all_chunks: List[Dict[str, Any]] = []
        for idx, func_data in enumerate(functions):
            if not isinstance(func_data, dict):
                continue

            name = func_data.get("name", "")
            params = func_data.get("params", {})

            if not name:
                continue

            parsed = ParsedFunctionCall(name=name, arguments=params)
            chunks = self._response_formatter.format_streaming_chunks(
                index=idx, parsed_call=parsed, chunk_size=chunk_size
            )
            all_chunks.extend(chunks)

        return all_chunks


# Module-level singleton for convenience
_orchestrator: Optional[FunctionCallingOrchestrator] = None


def get_function_calling_orchestrator() -> FunctionCallingOrchestrator:
    """Get or create the global function calling orchestrator instance."""
    global _orchestrator
    if _orchestrator is None:
        _orchestrator = FunctionCallingOrchestrator()
    return _orchestrator


def reset_orchestrator() -> None:
    """Reset the global orchestrator (useful for testing)."""
    global _orchestrator
    _orchestrator = None


# =============================================================================
# Convenience Functions for Request Processing Integration
# =============================================================================


def should_skip_tool_injection(
    tools: Optional[List[Dict[str, Any]]],
    fc_state: Optional[FunctionCallingState] = None,
) -> bool:
    """Determine if tool catalog injection should be skipped.

    In native mode, the tool catalog is configured via UI automation,
    so we should skip injecting it into the prompt text.

    When fc_state is provided (from prepare_request), it takes precedence
    over static config to handle AUTO mode fallback correctly.

    Args:
        tools: List of tool definitions from the request.
        fc_state: Optional dynamic state from FunctionCallingOrchestrator.
                  If provided, uses the actual resolved mode (handles fallback).

    Returns:
        True if tool injection should be skipped (native mode successfully configured),
        False if tool catalog should be injected (emulated mode or fallback).
    """
    if not tools or len(tools) == 0:
        return True  # No tools, nothing to inject anyway

    # If we have dynamic state from the orchestrator, use it
    # This correctly handles AUTO mode fallback scenarios
    if fc_state is not None:
        # Only skip injection if native mode was successfully configured
        if fc_state.native_enabled and fc_state.tools_configured:
            return True
        # If fallback was used, we need to inject tools
        if fc_state.fallback_used:
            return False
        # If mode is explicitly EMULATED (either configured or after fallback)
        if fc_state.mode == FunctionCallingMode.EMULATED:
            return False
        # Native/Auto mode attempted but tools not configured - inject as fallback
        if not fc_state.tools_configured:
            return False
        return True

    # Fall back to static config check (backwards compatibility)
    mode_str = FUNCTION_CALLING_MODE.lower()

    # In emulated mode, always inject tools into prompt
    if mode_str == "emulated":
        return False

    # In native or auto mode, ONLY skip if we're sure native mode is being used.
    # If fc_state is None, we don't know the dynamic state, so we default to
    # injecting tools into the prompt for safety (backwards compatibility).
    return False


def get_effective_function_calling_mode() -> FunctionCallingMode:
    """Get the currently configured function calling mode.

    Returns:
        The FunctionCallingMode enum value.
    """
    mode_str = FUNCTION_CALLING_MODE.lower()
    try:
        return FunctionCallingMode(mode_str)
    except ValueError:
        return FunctionCallingMode.EMULATED


__all__ = [
    "FunctionCallingOrchestrator",
    "FunctionCallingState",
    "NativeFunctionCallingError",
    "get_function_calling_orchestrator",
    "reset_orchestrator",
    "should_skip_tool_injection",
    "get_effective_function_calling_mode",
]