Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,48 @@ model prefixes).

Hosted inference is billed to the active Hugging Face user. See below on how to run `ml-intern` with local models.

#### OpenAI authentication through Codex

The local CLI can use an existing Codex login instead of sending model calls
through Hugging Face Router:

```bash
codex login
ml-intern --model codex/default
```

`codex/default` lets Codex select the current default model for the signed-in
account. To request a specific model available to that account:

```bash
ml-intern --model codex/<model-id>
```

This mode starts the local `codex app-server`; ML Intern never reads or copies
Codex's cached credentials. If `codex login` used **Sign in with ChatGPT**,
Codex usage follows that ChatGPT plan's Codex allowance. If Codex was logged in
with an OpenAI API key, standard API billing applies instead.

To expose the same signed-in Codex runtime in the local Web UI:

```bash
ML_INTERN_ENABLE_CODEX_WEB=1 uv run uvicorn backend.main:app --host ::1 --port 7860
```

The Web picker then loads the models available to the active Codex account and
their supported reasoning levels directly from `codex app-server`. `Codex Auto`
is the recommended/default choice; explicit Codex models and Thinking levels
can be changed per session. This flag is deliberately disabled when the backend
detects Hugging Face Spaces or OAuth: a developer's local ChatGPT allowance
must never be shared with hosted visitors.

Codex supplies the main agent loop. In the CLI it also has local repository
tools; in the Web UI the host repository stays read-only. ML Intern's Hugging
Face documentation, papers, datasets, Hub, Jobs, web research, and optional
sandbox tools are exposed to Codex under the `ml_intern` namespace. `HF_TOKEN`
is optional for the Codex model itself, but individual Hub/Jobs and sandbox
tools still require Hugging Face authentication.

#### Local models

Local model support uses OpenAI-compatible HTTP endpoints through LiteLLM. The
Expand Down
223 changes: 223 additions & 0 deletions agent/codex_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""CLI presentation for the OpenAI-authenticated Codex runtime."""

from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any

from prompt_toolkit import PromptSession

from agent.config import Config
from agent.core.approval_policy import is_scheduled_operation
from agent.core.codex_runtime import CodexAppServerRuntime, CodexRuntimeError
from agent.core.session import Event
from agent.core.tools import ToolRouter
from agent.utils.terminal_display import (
get_console,
print_banner,
print_error,
print_markdown,
print_tool_call,
print_tool_log,
print_tool_output,
)


def _is_scheduled_job(tool_name: str, arguments: dict[str, Any]) -> bool:
return tool_name == "hf_jobs" and is_scheduled_operation(arguments.get("operation"))


def _available_mcp_servers(config: Config, hf_token: str | None) -> dict:
"""Skip the default HF OAuth MCP when no local HF identity exists."""
if hf_token:
return config.mcpServers

available = {}
for name, server in config.mcpServers.items():
data = server.model_dump()
url = str(data.get("url") or "")
if "huggingface.co/mcp" in url:
continue
available[name] = server
return available


async def run_codex_interactive(
*,
config: Config,
prompt_session: PromptSession,
hf_token: str | None,
hf_user: str | None,
local_mode: bool,
) -> None:
"""Run the interactive ML Intern CLI on the authenticated Codex runtime."""
console = get_console()
print_banner(
model=config.model_name,
hf_user=hf_user,
tool_runtime="local filesystem" if local_mode else "HF sandbox",
)

streamed = [False]

async def on_delta(delta: str) -> None:
streamed[0] = True
console.file.write(delta)
console.file.flush()

async def on_tool(
name: str,
arguments: dict[str, Any],
output: str | None,
success: bool | None,
_tool_call_id: str,
) -> None:
if output is None:
print_tool_call(name, json.dumps(arguments)[:120])
else:
print_tool_output(output, bool(success), truncate=True)

async def on_event(event: Event) -> None:
if event.event_type == "tool_log" and event.data:
print_tool_log(
str(event.data.get("tool") or ""),
str(event.data.get("log") or ""),
)

async def approve_tool(
name: str,
arguments: dict[str, Any],
_tool_call_id: str,
) -> bool:
console.print(f"\n[bold yellow]Approval required:[/bold yellow] {name}")
console.print_json(data=arguments)
answer = await prompt_session.prompt_async("Approve this tool call? [y/N] ")
return answer.strip().lower() in {"y", "yes"}

tool_router = ToolRouter(
_available_mcp_servers(config, hf_token),
hf_token=hf_token,
local_mode=local_mode,
)

try:
async with CodexAppServerRuntime(
config=config,
tool_router=tool_router,
hf_token=hf_token,
local_mode=local_mode,
cwd=Path.cwd(),
autonomous_mode=False,
approve_tool=approve_tool,
on_delta=on_delta,
on_tool=on_tool,
on_event=on_event,
) as runtime:
console.print(f"[dim]{runtime.auth_status}[/dim]")
while True:
try:
user_input = await prompt_session.prompt_async("\nYou: ")
except (EOFError, KeyboardInterrupt):
break

stripped = user_input.strip()
if not stripped:
continue
if stripped.lower() in {"exit", "quit", "/quit", "/exit"}:
break
if stripped == "/status":
console.print(f"[bold]Model:[/bold] {config.model_name}")
console.print(f"[bold]Auth:[/bold] {runtime.auth_status}")
continue
if stripped == "/new":
await runtime.new_thread()
console.print("[green]Started a new Codex conversation.[/green]")
continue
if stripped.startswith("/model"):
console.print(
"[dim]Codex runtime models are selected at startup. "
"Restart with `ml-intern --model codex/default` or "
"`ml-intern --model codex/<model>`.[/dim]"
)
continue

streamed[0] = False
try:
final_text = await runtime.run_turn(stripped, stream=True)
except KeyboardInterrupt:
await runtime.interrupt()
console.print("\n[yellow]Interrupted.[/yellow]")
continue
if streamed[0]:
console.file.write("\n")
console.file.flush()
elif final_text:
await print_markdown(final_text, instant=True)
except CodexRuntimeError as exc:
print_error(str(exc))
finally:
console.print("\n[dim]Bye.[/dim]\n")


async def run_codex_headless(
prompt: str,
*,
config: Config,
hf_token: str | None,
local_mode: bool,
stream: bool,
) -> None:
"""Run one prompt through Codex and exit."""
streamed = [False]

async def on_delta(delta: str) -> None:
streamed[0] = True
sys.stdout.write(delta)
sys.stdout.flush()

async def on_tool(
name: str,
arguments: dict[str, Any],
output: str | None,
success: bool | None,
_tool_call_id: str,
) -> None:
if output is None:
print_tool_call(name, json.dumps(arguments)[:120])
else:
print_tool_output(output, bool(success), truncate=True)

async def approve_tool(
name: str,
arguments: dict[str, Any],
_tool_call_id: str,
) -> bool:
# Match the existing headless policy: scheduled Jobs never receive
# automatic approval because they can create recurring spend.
return not _is_scheduled_job(name, arguments)

tool_router = ToolRouter(
_available_mcp_servers(config, hf_token),
hf_token=hf_token,
local_mode=local_mode,
)
async with CodexAppServerRuntime(
config=config,
tool_router=tool_router,
hf_token=hf_token,
local_mode=local_mode,
cwd=Path.cwd(),
autonomous_mode=True,
approve_tool=approve_tool,
on_delta=on_delta,
on_tool=on_tool,
) as runtime:
print(f"Codex auth: {runtime.auth_status}", file=sys.stderr)
final_text = await runtime.run_turn(prompt, stream=stream)
if streamed[0]:
sys.stdout.write("\n")
sys.stdout.flush()
elif final_text:
await print_markdown(final_text, instant=True)
27 changes: 27 additions & 0 deletions agent/core/codex_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Model-id helpers for the Codex app-server runtime.

``codex/<model>`` is intentionally separate from the OpenAI-compatible
LiteLLM providers. Codex owns its authentication session and may use either a
ChatGPT subscription or an OpenAI API key, depending on how ``codex login`` was
completed. ML Intern never reads or forwards Codex's cached credentials.
"""

CODEX_MODEL_PREFIX = "codex/"
CODEX_DEFAULT_MODEL_ID = f"{CODEX_MODEL_PREFIX}default"


def is_codex_model_id(model_id: str | None) -> bool:
"""Return ``True`` for a well-formed Codex runtime model id."""
if not model_id or any(char.isspace() for char in model_id):
return False
return model_id.startswith(CODEX_MODEL_PREFIX) and bool(
model_id.removeprefix(CODEX_MODEL_PREFIX)
)


def codex_model_name(model_id: str) -> str | None:
"""Return the model passed to Codex, or ``None`` for its current default."""
if not is_codex_model_id(model_id):
raise ValueError(f"Unsupported Codex model id: {model_id}")
name = model_id.removeprefix(CODEX_MODEL_PREFIX)
return None if name == "default" else name
Loading
Loading