Skip to content
Merged
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
24 changes: 20 additions & 4 deletions veadk/cli/cli_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,13 @@ async def _delete_temp_agent(app_name: str):
oauth2_config = _build_generic_oauth2(provider_id or "custom", redirect_uri)
provider_id = provider_id or "custom"

# The SPA fetches /web/auth-config and /oauth2/userinfo on every startup, so
# both must always return JSON. With SSO off we answer with an empty provider
# list and a 401 (unauthenticated), and the app renders its normal no-login
# UI; otherwise the SPA-fallback serves the HTML shell for these paths and the
# app's `await res.json()` throws, leaving a white screen.
providers: list[dict] = []

if oauth2_config is not None:
from urllib.parse import urlsplit

Expand All @@ -950,10 +957,6 @@ async def _delete_temp_agent(app_name: str):
)
providers = [{"id": provider_id, "label": label, "loginUrl": "/oauth2/login"}]

@app.get("/web/auth-config")
async def _web_auth_config():
return {"providers": providers}

# Protect the API but exempt the SPA shell + this config endpoint so the
# app can load and render its own login page when not signed in.
setup_oauth2(
Expand All @@ -965,6 +968,19 @@ async def _web_auth_config():
logger.info(
f"OAuth2 SSO enabled (provider={provider_id}, redirect_uri={redirect_uri})"
)
else:
from fastapi.responses import JSONResponse

@app.get("/oauth2/userinfo")
async def _userinfo_no_sso():
# No SSO configured: report unauthenticated (401) so the SPA's auth
# check resolves cleanly instead of parsing the HTML shell as JSON.
return JSONResponse({"status": "unauthenticated"}, status_code=401)

@app.get("/web/auth-config")
async def _web_auth_config():
# Empty provider list when SSO is off -> the SPA shows its normal UI.
return {"providers": providers}

if dev:
logger.info(
Expand Down
40 changes: 35 additions & 5 deletions veadk/runtime/codex/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@
from typing import TYPE_CHECKING, AsyncGenerator

from openai_codex import AsyncCodex # type: ignore[import-not-found]
from openai_codex.generated.v2_all import ( # type: ignore[import-not-found]
ItemCompletedNotification,
TurnCompletedNotification,
)

from veadk.runtime.base_runtime import BaseRuntime, build_system_append
from veadk.runtime.codex.proxy import get_shim_url
from veadk.runtime.codex.translate import build_prompt, result_to_events
from veadk.runtime.codex.translate import build_prompt, item_to_events
from veadk.utils.logger import get_logger

if TYPE_CHECKING:
Expand Down Expand Up @@ -98,19 +102,45 @@ async def run_async(
os.environ["CODEX_HOME"] = codex_home
os.environ[_KEY_ENV] = _LOCAL_SHIM_TOKEN
try:
# Stream the turn: emit ADK events as each Codex item completes
# (reasoning, tool calls, messages) instead of collecting the whole
# turn first. This keeps the BaseRuntime async-generator contract
# truly incremental, so thinking/tool steps show up live (a blocking
# thread.run() would leave the client silent for the whole turn).
async with AsyncCodex() as codex:
thread = await codex.thread_start(model=model)
result = await thread.run(prompt)
turn = await thread.turn(prompt)
stream = turn.stream()
try:
async for note in stream:
payload = note.payload
if (
isinstance(payload, ItemCompletedNotification)
and payload.turn_id == turn.id
):
for event in item_to_events(
payload.item, agent.name, ctx.invocation_id
):
yield event
elif (
isinstance(payload, TurnCompletedNotification)
and payload.turn.id == turn.id
and payload.turn.error
):
raise RuntimeError(payload.turn.error.message)
finally:
# stream() is an async generator at runtime; close it to
# unregister the turn's notification listener.
aclose = getattr(stream, "aclose", None)
if aclose is not None:
await aclose()
finally:
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value

for event in result_to_events(result, agent.name, ctx.invocation_id):
yield event

def _resolve_model(self, agent: "Agent") -> str:
name = agent.model_name
if isinstance(name, list):
Expand Down
128 changes: 66 additions & 62 deletions veadk/runtime/codex/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,87 +173,91 @@ def _event(author: str, invocation_id: str, role: str, part: types.Part) -> Even
)


def result_to_events(result: Any, author: str, invocation_id: str) -> list[Event]:
"""Convert a Codex run result into ADK events, faithfully and in order.
def item_to_events(item: Any, author: str, invocation_id: str) -> list[Event]:
"""Convert a single Codex thread item into ADK events.

A Codex turn is multi-step. Rather than collapse it to ``final_response``,
walk ``result.items`` and forward each step as its own ADK event, mapping
Codex thread items onto the genai part the matching ADK event expects:
Maps one item onto the genai part the matching ADK event expects:

- ``reasoning`` -> a thought text part,
- tool calls (``commandExecution`` / ``mcpToolCall`` / ``dynamicToolCall``
/ ``fileChange`` / ``webSearch``) -> a ``function_call`` part plus a
matching ``function_response`` part carrying the tool's output,
- ``agentMessage`` / ``plan`` / any other text-bearing item -> a text part,
- ``userMessage`` -> skipped (ADK already owns the user turn).
- ``userMessage`` (and anything else) -> nothing.

If nothing maps, fall back to ``final_response`` so a turn is never empty.
Returning per-item keeps the conversion reusable both for the streaming
path (emit as each item completes) and the batch path below.

Args:
result (Any): The object returned by ``thread.run(...)``.
item (Any): A Codex ``ThreadItem`` (model or dict).
author (str): Event author (the agent name).
invocation_id (str): The ADK invocation id to stamp on each event.

Returns:
list[google.adk.events.event.Event]: The turn's events in order.
list[google.adk.events.event.Event]: 0-2 events for this item.
"""
events: list[Event] = []
for item in getattr(result, "items", None) or []:
data = _item_dict(item)
itype = str(data.get("type", ""))

if itype == "userMessage":
continue
data = _item_dict(item)
itype = str(data.get("type", ""))

if itype == "reasoning":
text = _join(data.get("summary")) or _join(data.get("content"))
if text:
events.append(
_event(
author,
invocation_id,
"model",
types.Part(text=text, thought=True),
if itype == "reasoning":
text = _join(data.get("summary")) or _join(data.get("content"))
if not text:
return []
return [
_event(author, invocation_id, "model", types.Part(text=text, thought=True))
]

call = _tool_call(data)
if call is not None:
name, args, response = call
call_id = data.get("id") or f"call_{itype}"
return [
_event(
author,
invocation_id,
"model",
types.Part(
function_call=types.FunctionCall(id=call_id, name=name, args=args)
),
),
_event(
author,
invocation_id,
"user",
types.Part(
function_response=types.FunctionResponse(
id=call_id, name=name, response=response
)
)
continue
),
),
]

call = _tool_call(data)
if call is not None:
name, args, response = call
call_id = data.get("id") or f"call_{len(events)}"
events.append(
_event(
author,
invocation_id,
"model",
types.Part(
function_call=types.FunctionCall(
id=call_id, name=name, args=args
)
),
)
)
events.append(
_event(
author,
invocation_id,
"user",
types.Part(
function_response=types.FunctionResponse(
id=call_id, name=name, response=response
)
),
)
)
continue
if itype != "userMessage" and data.get("text"):
return [
_event(author, invocation_id, "model", types.Part(text=str(data["text"])))
]

if data.get("text"): # agentMessage, plan, and any text-bearing item
events.append(
_event(
author, invocation_id, "model", types.Part(text=str(data["text"]))
)
)
return []


def result_to_events(result: Any, author: str, invocation_id: str) -> list[Event]:
"""Convert a whole Codex run result into ADK events, in order.

Walks ``result.items`` through :func:`item_to_events`. Falls back to
``final_response`` so a turn is never empty. (The streaming runtime path
calls :func:`item_to_events` per completed item instead of this.)

Args:
result (Any): The object returned by ``thread.run(...)``.
author (str): Event author (the agent name).
invocation_id (str): The ADK invocation id to stamp on each event.

Returns:
list[google.adk.events.event.Event]: The turn's events in order.
"""
events: list[Event] = []
for item in getattr(result, "items", None) or []:
events.extend(item_to_events(item, author, invocation_id))

if events:
return events
Expand Down
Loading