|
4 | 4 |
|
5 | 5 | import os |
6 | 6 | import re |
| 7 | +import shlex |
| 8 | +import subprocess |
7 | 9 | from pathlib import Path |
8 | 10 |
|
9 | 11 | from ucode.agent_updates import available_npm_package_update |
|
33 | 35 | CODEX_MODEL_PROVIDER_NAME = "ucode-databricks" |
34 | 36 | MINIMUM_CODEX_VERSION = (0, 134, 0) |
35 | 37 | MINIMUM_CODEX_VERSION_TEXT = "0.134.0" |
| 38 | +MINIMUM_ROUTING_CODEX_VERSION = (0, 145, 0) |
| 39 | +MINIMUM_ROUTING_CODEX_VERSION_TEXT = "0.145.0" |
| 40 | +INTELLIGENT_ROUTING_STATE_KEY = "codex_intelligent_routing_enabled" |
| 41 | +ROUTING_HOOK_COMMAND_MARKER = "codex-router-hook" |
36 | 42 |
|
37 | 43 |
|
38 | 44 | SPEC: ToolSpec = { |
@@ -318,6 +324,11 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non |
318 | 324 | databricks_profile = state.get("profile") |
319 | 325 |
|
320 | 326 | if _use_legacy_layout(): |
| 327 | + if intelligent_routing_enabled(state) and provider is None: |
| 328 | + raise RuntimeError( |
| 329 | + "Codex intelligent routing requires Codex " |
| 330 | + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer." |
| 331 | + ) |
321 | 332 | # Codex < 0.134.0 only reads ~/.codex/config.toml. Write the shared |
322 | 333 | # config with [profiles.ucode] + shared [model_providers.ucode-databricks] |
323 | 334 | # and skip the per-profile-file cleanup that would normally strip |
@@ -358,6 +369,11 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non |
358 | 369 | # deep_merge can't drop keys, so clear a `model` pinned by an earlier |
359 | 370 | # non-provider run that the provider overlay omits. |
360 | 371 | doc.pop("model", None) |
| 372 | + _sync_intelligent_routing_hooks( |
| 373 | + doc, |
| 374 | + state, |
| 375 | + enabled=intelligent_routing_enabled(state) and provider is None, |
| 376 | + ) |
361 | 377 | write_toml_file(CODEX_CONFIG_PATH, doc) |
362 | 378 | state = mark_tool_managed(state, "codex", MANAGED_KEYS) |
363 | 379 | save_state(state) |
@@ -399,6 +415,181 @@ def launch(state: dict, tool_args: list[str]) -> None: |
399 | 415 | exec_or_spawn([binary, "--profile", CODEX_PROFILE_NAME, *tool_args]) |
400 | 416 |
|
401 | 417 |
|
| 418 | +def intelligent_routing_enabled(state: dict) -> bool: |
| 419 | + """Return whether the current workspace opted into Codex routing.""" |
| 420 | + return state.get(INTELLIGENT_ROUTING_STATE_KEY) is True |
| 421 | + |
| 422 | + |
| 423 | +def enable_intelligent_routing(state: dict) -> dict: |
| 424 | + """Persist the current workspace's Codex intelligent-routing opt-in.""" |
| 425 | + parsed = _parse_version(agent_version(SPEC["binary"])) |
| 426 | + if parsed is not None and parsed < MINIMUM_ROUTING_CODEX_VERSION: |
| 427 | + raise RuntimeError( |
| 428 | + "Codex intelligent routing requires Codex " |
| 429 | + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found " |
| 430 | + f"{agent_version(SPEC['binary'])}." |
| 431 | + ) |
| 432 | + state[INTELLIGENT_ROUTING_STATE_KEY] = True |
| 433 | + return state |
| 434 | + |
| 435 | + |
| 436 | +def disable_intelligent_routing(state: dict) -> bool: |
| 437 | + """Disable routing and remove only ucode's Codex routing hooks.""" |
| 438 | + state.pop(INTELLIGENT_ROUTING_STATE_KEY, None) |
| 439 | + if state.get("workspace"): |
| 440 | + save_state(state) |
| 441 | + changed = False |
| 442 | + for path in (CODEX_CONFIG_PATH, LEGACY_CODEX_CONFIG_PATH): |
| 443 | + if not path.exists(): |
| 444 | + continue |
| 445 | + doc = read_toml_safe(path) |
| 446 | + if _remove_intelligent_routing_hooks(doc): |
| 447 | + write_toml_file(path, doc) |
| 448 | + changed = True |
| 449 | + from ucode.codex_routing import clear_routing_artifacts |
| 450 | + |
| 451 | + clear_routing_artifacts() |
| 452 | + return changed |
| 453 | + |
| 454 | + |
| 455 | +def route_launch_model(state: dict, tool_args: list[str]): |
| 456 | + """Route a root Codex launch before the Codex process starts.""" |
| 457 | + from ucode.codex_routing import request_routing_decision |
| 458 | + |
| 459 | + workspace = state.get("workspace") |
| 460 | + models = state.get("codex_models") |
| 461 | + if not isinstance(workspace, str) or not isinstance(models, list): |
| 462 | + return None, "workspace model metadata is unavailable" |
| 463 | + try: |
| 464 | + token = get_databricks_token(workspace, state.get("profile")) |
| 465 | + except RuntimeError as exc: |
| 466 | + return None, f"could not authenticate the routing request: {exc}" |
| 467 | + task = _launch_routing_task(tool_args) |
| 468 | + return request_routing_decision(workspace, token, task, models) |
| 469 | + |
| 470 | + |
| 471 | +def _launch_routing_task(tool_args: list[str]) -> str: |
| 472 | + if "exec" in tool_args: |
| 473 | + prompt_parts = tool_args[tool_args.index("exec") + 1 :] |
| 474 | + if prompt_parts: |
| 475 | + return " ".join(prompt_parts) |
| 476 | + if tool_args: |
| 477 | + return "Start a Codex session with options: " + " ".join(tool_args) |
| 478 | + return f"Start an interactive Codex coding session in {Path.cwd().name}." |
| 479 | + |
| 480 | + |
| 481 | +def _sync_intelligent_routing_hooks(doc: dict, state: dict, *, enabled: bool) -> None: |
| 482 | + _remove_intelligent_routing_hooks(doc) |
| 483 | + if not enabled: |
| 484 | + return |
| 485 | + hooks = doc.setdefault("hooks", {}) |
| 486 | + for event, groups in _routing_hook_groups(state).items(): |
| 487 | + existing = hooks.get(event) |
| 488 | + if not isinstance(existing, list): |
| 489 | + existing = [] |
| 490 | + hooks[event] = [*existing, *groups] |
| 491 | + |
| 492 | + |
| 493 | +def _routing_hook_groups(state: dict) -> dict[str, list[dict]]: |
| 494 | + route_argv = _routing_hook_argv(state, "route-subagent") |
| 495 | + session_argv = _routing_hook_argv(state, "session-start") |
| 496 | + subagent_argv = _routing_hook_argv(state, "record-subagent") |
| 497 | + return { |
| 498 | + "PreToolUse": [ |
| 499 | + { |
| 500 | + "matcher": "Agent|.*spawn_agent$", |
| 501 | + "hooks": [_routing_command_hook(route_argv, status="Routing subagent model")], |
| 502 | + } |
| 503 | + ], |
| 504 | + "SessionStart": [ |
| 505 | + { |
| 506 | + "matcher": "startup|resume|clear", |
| 507 | + "hooks": [_routing_command_hook(session_argv)], |
| 508 | + } |
| 509 | + ], |
| 510 | + "SubagentStart": [ |
| 511 | + { |
| 512 | + "hooks": [_routing_command_hook(subagent_argv)], |
| 513 | + } |
| 514 | + ], |
| 515 | + } |
| 516 | + |
| 517 | + |
| 518 | +def _routing_hook_argv(state: dict, event: str) -> list[str]: |
| 519 | + workspace = str(state.get("workspace") or "") |
| 520 | + argv = [ |
| 521 | + build_auth_token_argv(workspace, state.get("profile"), use_pat=bool(state.get("use_pat")))[ |
| 522 | + 0 |
| 523 | + ], |
| 524 | + ROUTING_HOOK_COMMAND_MARKER, |
| 525 | + event, |
| 526 | + ] |
| 527 | + if event != "route-subagent": |
| 528 | + return argv |
| 529 | + argv += ["--host", workspace] |
| 530 | + profile = state.get("profile") |
| 531 | + if isinstance(profile, str) and profile: |
| 532 | + argv += ["--profile", profile] |
| 533 | + if state.get("use_pat"): |
| 534 | + argv.append("--use-pat") |
| 535 | + for model in state.get("codex_models") or []: |
| 536 | + if isinstance(model, str) and model: |
| 537 | + argv += ["--model", model] |
| 538 | + return argv |
| 539 | + |
| 540 | + |
| 541 | +def _routing_command_hook(argv: list[str], *, status: str | None = None) -> dict: |
| 542 | + hook = { |
| 543 | + "type": "command", |
| 544 | + "command": shlex.join(argv), |
| 545 | + "command_windows": subprocess.list2cmdline(argv), |
| 546 | + "timeout": 35, |
| 547 | + } |
| 548 | + if status: |
| 549 | + hook["statusMessage"] = status |
| 550 | + return hook |
| 551 | + |
| 552 | + |
| 553 | +def _remove_intelligent_routing_hooks(doc: dict) -> bool: |
| 554 | + hooks = doc.get("hooks") |
| 555 | + if not isinstance(hooks, dict): |
| 556 | + return False |
| 557 | + changed = False |
| 558 | + for event in list(hooks): |
| 559 | + groups = hooks.get(event) |
| 560 | + if not isinstance(groups, list): |
| 561 | + continue |
| 562 | + kept_groups = [] |
| 563 | + for group in groups: |
| 564 | + if not isinstance(group, dict): |
| 565 | + kept_groups.append(group) |
| 566 | + continue |
| 567 | + handlers = group.get("hooks") |
| 568 | + if not isinstance(handlers, list): |
| 569 | + kept_groups.append(group) |
| 570 | + continue |
| 571 | + kept_handlers = [ |
| 572 | + handler |
| 573 | + for handler in handlers |
| 574 | + if not ( |
| 575 | + isinstance(handler, dict) |
| 576 | + and ROUTING_HOOK_COMMAND_MARKER in str(handler.get("command") or "") |
| 577 | + ) |
| 578 | + ] |
| 579 | + if len(kept_handlers) != len(handlers): |
| 580 | + changed = True |
| 581 | + if kept_handlers: |
| 582 | + group["hooks"] = kept_handlers |
| 583 | + kept_groups.append(group) |
| 584 | + if kept_groups: |
| 585 | + hooks[event] = kept_groups |
| 586 | + else: |
| 587 | + hooks.pop(event, None) |
| 588 | + if not hooks: |
| 589 | + doc.pop("hooks", None) |
| 590 | + return changed |
| 591 | + |
| 592 | + |
402 | 593 | def validate_cmd(binary: str) -> list[str]: |
403 | 594 | return [ |
404 | 595 | binary, |
|
0 commit comments