feat(pi): Add searchable model picker for new sessions with Databricks Unity AI Gateway OAuth (#4961)

* feat(pi): add searchable start model picker

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* fix(pi): harden model picker compatibility

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* refactor(pi): simplify model picker filtering

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
This commit is contained in:
Tomu Hirata
2026-08-19 09:02:35 +09:00
committed by GitHub
parent 70ee54bdba
commit adcf83ccb6
12 changed files with 605 additions and 67 deletions
+18
View File
@@ -2322,6 +2322,24 @@ class HostProcess:
models=models,
)
if harness == "pi-native":
try:
from omnigent.pi_native_credentials import pi_native_model_options
pi_models = await asyncio.to_thread(pi_native_model_options)
except Exception:
_logger.exception("Failed to resolve pre-launch Pi model options")
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Pi model options",
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=pi_models,
)
if harness != "claude-native":
return HostModelOptionsResultFrame(
request_id=frame.request_id,
+63 -14
View File
@@ -95,6 +95,7 @@ _SURFACE_PROVIDER_IDS: dict[DatabricksPiSurface, str] = {
DatabricksPiSurface.MLFLOW: _PI_MLFLOW_PROVIDER_ID,
}
_PI_MANAGED_PROVIDER_IDS = frozenset({_PI_PROVIDER_ID, *_SURFACE_PROVIDER_IDS.values()})
# Databricks AI Gateway Anthropic Messages surface. Pi speaks this protocol
# natively (``api: anthropic-messages``); the gateway authenticates with a
# workspace bearer token, so we set ``authHeader`` (Authorization: Bearer).
@@ -120,6 +121,16 @@ _is_databricks_ai_gateway_url = is_databricks_ai_gateway_url
_PiModelEntry: TypeAlias = PiModelEntry
def _split_pi_native_model_selection(selection: str | None) -> tuple[str, str] | None:
"""Split an Omnigent-managed ``provider/model`` picker value."""
if not selection:
return None
provider_id, separator, model_id = selection.partition("/")
if separator and provider_id in _PI_MANAGED_PROVIDER_IDS and model_id:
return provider_id, model_id
return None
class _PiProviderCompat(TypedDict):
supportsDeveloperRole: bool
supportsStore: bool
@@ -356,6 +367,25 @@ class PiProviderConfig:
)
def pi_native_model_options() -> list[dict[str, object]]:
"""Return pre-launch Pi choices configured through ``omni setup``."""
provider = resolve_pi_native_provider()
if provider is None:
return []
options: dict[str, dict[str, object]] = {}
for provider_id, payload in provider.to_models_config()["providers"].items():
for model in payload["models"]:
model_id = model["id"]
qualified = f"{provider_id}/{model_id}"
options[qualified] = {
"id": qualified,
"model": qualified,
"displayName": model.get("name") or model_id,
}
return [options[model_id] for model_id in sorted(options)]
# DATABRICKS-PATCH(pi-live-model-discovery)
def _default_claude_model_from(entries: list[_PiModelEntry]) -> str | None:
"""Pick pi's launch model from the workspace's live Claude entries.
@@ -979,6 +1009,9 @@ def resolve_pi_native_provider(
:returns: The resolved provider config, or ``None`` to fall back to Pi's
own credentials.
"""
selection = _split_pi_native_model_selection(model)
if selection is not None:
_, model = selection
try:
config = config_loader()
# Pi is multi-family; ``omnigent setup`` marks defaults per family, not
@@ -1085,12 +1118,16 @@ def write_pi_models_config(
def pi_native_provider_launch(
agent_dir: Path, provider: PiProviderConfig
agent_dir: Path,
provider: PiProviderConfig,
*,
selection: str | None = None,
) -> tuple[dict[str, str], list[str]]:
"""Write the managed config and return the launch env + CLI args for Pi.
:param agent_dir: The managed Pi config dir for this session.
:param provider: The resolved provider config.
:param selection: Optional picker value used to select a generated provider.
:returns: ``(env, args)`` — the env vars to merge into the terminal spec
(relocating Pi's config dir) and the ``--provider``/``--model`` args to
append to the Pi command.
@@ -1098,6 +1135,30 @@ def pi_native_provider_launch(
# Render once and reuse: rendering logs how an uncataloged model was routed,
# and this function both writes the config and reads it back for --provider.
rendered = provider.to_models_config()
# Resolve which provider the selected model lives in. Non-Claude models
# (GLM, GPT, Llama…) are in secondary providers; Claude models are in the
# primary provider. Read the rendered config so family fallbacks agree.
selected_model = provider.model
model_provider_id = provider.provider_id
selection_parts = _split_pi_native_model_selection(selection)
if selection_parts is not None:
candidate_provider, candidate_model = selection_parts
configured = rendered["providers"].get(candidate_provider)
if not configured or not any(
model.get("id") == candidate_model for model in configured.get("models", [])
):
raise ValueError(
f"Pi model selection {selection!r} is not available in managed configuration"
)
model_provider_id = candidate_provider
selected_model = candidate_model
else:
for extra_id, extra_cfg in rendered["providers"].items():
if extra_id == provider.provider_id:
continue
if any(m.get("id") == provider.model for m in extra_cfg.get("models", [])):
model_provider_id = extra_id
break
write_pi_models_config(agent_dir, provider, rendered)
# Copy the user's global Pi settings but suppress defaultThinkingLevel.
# In TUI mode Pi applies the setting from ~/.pi/agent/settings.json; for
@@ -1111,25 +1172,13 @@ def pi_native_provider_launch(
prepare_managed_pi_agent_dir(agent_dir, overlay={"defaultThinkingLevel": None})
env = {PI_CODING_AGENT_DIR_ENV_VAR: str(agent_dir)}
# Resolve which provider the selected model lives in. Non-Claude models
# (GLM, GPT, Llama…) are in secondary providers (omnigent-openai); Claude
# models are in the primary provider (omnigent). Read the *rendered* config
# rather than additional_providers so a model routed by the family fallback
# gets the same --provider that models.json registered it under.
model_provider_id = provider.provider_id
for extra_id, extra_cfg in rendered["providers"].items():
if extra_id == provider.provider_id:
continue
if any(m.get("id") == provider.model for m in extra_cfg.get("models", [])):
model_provider_id = extra_id
break
# When the model id contains a "/" Pi's arg parser splits on the first
# slash and treats the left part as a provider name, overriding
# --provider. Pass the fully-qualified "provider/model" reference so Pi's
# findExactModelReferenceMatch matches the canonical form exactly and
# routes to our custom provider, not a builtin with the same model id.
model_arg = (
f"{model_provider_id}/{provider.model}" if "/" in provider.model else provider.model
f"{model_provider_id}/{selected_model}" if "/" in selected_model else selected_model
)
args = ["--provider", model_provider_id, "--model", model_arg]
# For non-Claude models on openai-completions/responses, disable thinking.
@@ -357,7 +357,11 @@ function piResultFromMcpResponse(json) {
// masquerading as a successful tool result. callOmnigentTool detects and
// resolves the ASK round-trip BEFORE calling this; treat a stray one as a
// fail-closed error so an unresolved approval never reports success.
if (result && typeof result === "object" && result.resultType === "input_required") {
if (
result &&
typeof result === "object" &&
result.resultType === "input_required"
) {
return {
content: [
{
@@ -371,7 +375,11 @@ function piResultFromMcpResponse(json) {
if (result && Array.isArray(result.content)) {
const parts = [];
for (const block of result.content) {
if (block && typeof block === "object" && typeof block.text === "string") {
if (
block &&
typeof block === "object" &&
typeof block.text === "string"
) {
parts.push(block.text);
}
}
@@ -400,7 +408,11 @@ function piResultFromMcpResponse(json) {
*/
function mcpInputRequired(json) {
const result = json && typeof json === "object" ? json.result : undefined;
if (!result || typeof result !== "object" || result.resultType !== "input_required") {
if (
!result ||
typeof result !== "object" ||
result.resultType !== "input_required"
) {
return null;
}
const inputRequests =
@@ -894,7 +906,27 @@ async function applyModelChange(pi, config, ctx, modelId) {
}
let model;
try {
model = listModels().find((m) => m && m.id === id);
const models = listModels();
const separator = id.indexOf("/");
if (separator > 0) {
const provider = id.slice(0, separator);
const bareId = id.slice(separator + 1);
model = models.find(
(candidate) =>
candidate && candidate.id === bareId && candidate.provider === provider,
);
if (!model) model = models.find((candidate) => candidate && candidate.id === id);
if (!model && registry && typeof registry.find === "function") {
model = registry.find(provider, bareId);
}
if (!model) {
model = models.find(
(candidate) => candidate && candidate.id === bareId && !candidate.provider,
);
}
} else {
model = models.find((candidate) => candidate && candidate.id === id);
}
} catch (_err) {
model = undefined;
}
@@ -940,6 +972,13 @@ async function postModelChangeError(config, message) {
});
}
function modelReference(model) {
const modelId = model && typeof model.id === "string" ? model.id : "";
if (!modelId) return "";
const provider = model && typeof model.provider === "string" ? model.provider : "";
return provider ? `${provider}/${modelId}` : modelId;
}
/**
* Report Pi's live model catalog to Omnigent for the Web UI model picker.
*
@@ -977,11 +1016,13 @@ async function postModelOptions(config, ctx) {
const options = [];
const seen = new Set();
for (const model of models) {
const id = model && typeof model.id === "string" ? model.id : "";
const modelId = model && typeof model.id === "string" ? model.id : "";
const id = modelReference(model);
if (!id || seen.has(id)) continue;
seen.add(id);
const name = model && typeof model.name === "string" && model.name ? model.name : id;
options.push({ id, displayName: name });
const name =
model && typeof model.name === "string" && model.name ? model.name : modelId;
options.push({ id, model: id, displayName: name });
}
if (options.length === 0) return;
await postEvent(config, {
@@ -990,7 +1031,13 @@ async function postModelOptions(config, ctx) {
});
}
function startInboxPoller(pi, config, handleInterrupt, handleCompact, handleModelChange) {
function startInboxPoller(
pi,
config,
handleInterrupt,
handleCompact,
handleModelChange,
) {
if (!config || !config.inboxDir || pi.__omnigentInboxPoller) return;
// Bound the dedup set (FIFO eviction) — delivered files are unlinked, so a
// long-lived TUI mustn't grow it unboundedly.
@@ -1408,7 +1455,8 @@ module.exports = function (pi) {
// carries no identity field at all.
function usageMessageKey(message, usage) {
if (message && typeof message === "object") {
if (typeof message.id === "string" && message.id) return `id:${message.id}`;
if (typeof message.id === "string" && message.id)
return `id:${message.id}`;
if (typeof message.responseId === "string" && message.responseId)
return `rid:${message.responseId}`;
if (typeof message.timestamp === "number")
@@ -1721,12 +1769,11 @@ module.exports = function (pi) {
// ``/login`` session (no Omnigent ``model_override``, no ``llm_model``)
// shows no active model until the user switches. Mirrors the
// ``model_select`` handler, but for the startup value ``ctx.model``.
const startupModelId =
ctx && ctx.model && typeof ctx.model.id === "string" ? ctx.model.id : "";
if (startupModelId) {
const startupModel = modelReference(ctx ? ctx.model : undefined);
if (startupModel) {
await postEvent(config, {
type: "external_model_change",
data: { model: startupModelId },
data: { model: startupModel },
});
}
await postEvent(config, {
@@ -1749,14 +1796,15 @@ module.exports = function (pi) {
// web-side override. The server dedups against ``model_override``, so a
// web-initiated switch (which already persisted the value before queuing
// the inbox ``model_change``) round-trips here as a no-op.
const source = event && typeof event.source === "string" ? event.source : "";
const source =
event && typeof event.source === "string" ? event.source : "";
if (source === "restore") return;
const model = event && event.model ? event.model : undefined;
const modelId = model && typeof model.id === "string" ? model.id : "";
if (!modelId) return;
const selectedModel = modelReference(model);
if (!selectedModel) return;
await postEvent(config, {
type: "external_model_change",
data: { model: modelId },
data: { model: selectedModel },
});
});
@@ -1814,7 +1862,8 @@ module.exports = function (pi) {
if (changed) await postSessionUsage();
// Reuse the agent_start response_id so the web client matches the idle
// edge and clears the "streaming" status, unblocking queued follow-ups.
const endResponseId = turnStatusResponseId ?? `pi-${Date.now()}-${++sequence}`;
const endResponseId =
turnStatusResponseId ?? `pi-${Date.now()}-${++sequence}`;
turnStatusResponseId = null;
await postEvent(config, {
type: "external_session_status",
@@ -1965,9 +2014,13 @@ module.exports = function (pi) {
// unsupported API types) as visible error items in the web UI so users
// aren't left staring at an empty turn.
const stopReason =
message && typeof message.stopReason === "string" ? message.stopReason : "";
message && typeof message.stopReason === "string"
? message.stopReason
: "";
const errorMessage =
message && typeof message.errorMessage === "string" ? message.errorMessage : "";
message && typeof message.errorMessage === "string"
? message.errorMessage
: "";
if (stopReason === "error" && errorMessage) {
await postEvent(config, {
type: "external_conversation_item",
+7 -8
View File
@@ -2158,17 +2158,16 @@ async def _auto_create_pi_terminal(
resolve_pi_native_provider,
)
# Thread the agent spec's pinned model (``executor.model``) into the
# resolved provider so the generated ``models.json`` — and the
# appended ``--model`` arg (see ``pi_native_provider_launch``) — select
# it, reaching parity with claude-native / cursor-native. ``None``
# (no model declared) keeps the provider's default model.
# model_override (set by /model or sys_session_create's model arg)
# takes precedence over the spec's pinned executor.model.
# Provider-qualified picker values select one of the models rendered
# from the provider configured through ``omni setup``.
spec_model = launch_config.model_override or _pi_native_model_from_spec(agent_spec)
provider = resolve_pi_native_provider(model=spec_model)
if provider is not None:
cred_env, cred_args = pi_native_provider_launch(bridge_dir / "pi-agent", provider)
cred_env, cred_args = pi_native_provider_launch(
bridge_dir / "pi-agent",
provider,
selection=spec_model,
)
pi_env.update(cred_env)
pi_args.extend(cred_args)
# An unroutable model leaves Pi unable to select it, which looks
+36
View File
@@ -123,6 +123,42 @@ async def test_handle_model_options_uses_host_claude_configuration(
)
async def test_handle_model_options_uses_host_pi_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pi's launch picker uses only models configured through Omnigent."""
from omnigent import pi_native_credentials
monkeypatch.setattr(
pi_native_credentials,
"pi_native_model_options",
lambda: [
{
"id": "omnigent-openai/system.ai.gpt-5-6-sol",
"model": "omnigent-openai/system.ai.gpt-5-6-sol",
"displayName": "omnigent-openai/GPT 5.6 Sol",
}
],
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_pi_models", harness="pi-native"),
)
assert result == HostModelOptionsResultFrame(
request_id="req_pi_models",
status="ok",
models=[
{
"id": "omnigent-openai/system.ai.gpt-5-6-sol",
"model": "omnigent-openai/system.ai.gpt-5-6-sol",
"displayName": "omnigent-openai/GPT 5.6 Sol",
}
],
)
async def test_handle_model_options_uses_codex_provider_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -257,7 +257,7 @@ async def test_auto_create_pi_terminal_surfaces_credential_warning(
monkeypatch.setattr(
pi_native_credentials,
"pi_native_provider_launch",
lambda _agent_dir, _provider: ({}, []),
lambda _agent_dir, _provider, **_kwargs: ({}, []),
)
async def _fake_launch_config(**_kwargs: Any) -> _PiNativeLaunchConfig:
+116
View File
@@ -154,6 +154,29 @@ def test_key_provider_resolves_to_inline_family() -> None:
assert provider.model == "claude-sonnet-4-6"
def test_managed_picker_prefix_is_not_part_of_provider_model() -> None:
"""A managed picker value resolves its provider-local model id."""
config = {
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key": "sk-test-literal",
},
}
}
}
provider = creds.resolve_pi_native_provider(
model="omnigent/claude-opus-4-7", config_loader=lambda: config
)
assert provider is not None
assert provider.model == "claude-opus-4-7"
def test_subscription_default_returns_none() -> None:
"""A subscription (CLI-login) default isn't reusable by Pi → None."""
config = {"providers": {"claude": {"kind": "subscription", "default": True, "cli": "claude"}}}
@@ -283,6 +306,99 @@ def test_pi_native_provider_launch_namespaced_model_uses_qualified_arg(
assert args == ["--provider", "omnigent", "--model", "omnigent/moonshotai/kimi-k2.5"]
def test_provider_launch_accepts_provider_qualified_selection(tmp_path: Path) -> None:
"""A start-picker selection chooses its generated Pi provider and model."""
provider = creds.PiProviderConfig(
provider_id="omnigent",
base_url="https://api.anthropic.com",
api="anthropic-messages",
model="claude-sonnet-4-6",
api_key="sk-secret",
auth_header=False,
additional_providers={
"omnigent-openai": {
"baseUrl": "https://api.openai.com/v1",
"api": "openai-responses",
"apiKey": "sk-openai",
"models": [{"id": "gpt-5.6-sol"}],
}
},
)
_, args = creds.pi_native_provider_launch(
tmp_path / "pi-agent",
provider,
selection="omnigent-openai/gpt-5.6-sol",
)
assert args == [
"--provider",
"omnigent-openai",
"--model",
"gpt-5.6-sol",
"--thinking",
"off",
]
def test_provider_launch_rejects_unavailable_qualified_selection(tmp_path: Path) -> None:
"""A stale picker value must not silently launch the provider default."""
provider = creds.PiProviderConfig(
provider_id="omnigent",
base_url="https://api.anthropic.com",
api="anthropic-messages",
model="claude-sonnet-4-6",
api_key="sk-secret",
auth_header=False,
)
agent_dir = tmp_path / "pi-agent"
with pytest.raises(ValueError, match="not available"):
creds.pi_native_provider_launch(
agent_dir,
provider,
selection="omnigent-openai/gpt-missing",
)
assert not agent_dir.exists()
def test_pi_native_model_options_lists_only_managed_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pre-launch choices come only from the provider built by ``omni setup``."""
provider = creds.PiProviderConfig(
provider_id="omnigent",
base_url="https://api.anthropic.com",
api="anthropic-messages",
model="claude-sonnet-4-6",
api_key="sk-secret",
auth_header=False,
additional_providers={
"omnigent-openai": {
"baseUrl": "https://api.openai.com/v1",
"api": "openai-responses",
"apiKey": "sk-openai",
"models": [{"id": "gpt-5.6-sol", "name": "GPT 5.6 Sol"}],
}
},
)
monkeypatch.setattr(creds, "resolve_pi_native_provider", lambda: provider)
assert creds.pi_native_model_options() == [
{
"id": "omnigent-openai/gpt-5.6-sol",
"model": "omnigent-openai/gpt-5.6-sol",
"displayName": "GPT 5.6 Sol",
},
{
"id": "omnigent/claude-sonnet-4-6",
"model": "omnigent/claude-sonnet-4-6",
"displayName": "claude-sonnet-4-6",
},
]
def test_openai_chat_wire_api_resolves_to_completions(monkeypatch: pytest.MonkeyPatch) -> None:
"""An OpenAI family with wire_api: chat → openai-completions API.
+64 -11
View File
@@ -2668,9 +2668,9 @@ const setModelCalls = [];
// The catalog Pi's modelRegistry exposes; setModel returns false for a model
// with no configured API key (mirrors Pi's real contract).
const catalog = [
{ id: "databricks-claude-sonnet-4-6", name: "Sonnet", hasKey: true },
{ id: "databricks-claude-opus-4-1", name: "Opus", hasKey: true },
{ id: "no-key-model", name: "NoKey", hasKey: false },
{ provider: "omnigent", id: "databricks-claude-sonnet-4-6", name: "Sonnet", hasKey: true },
{ provider: "omnigent", id: "databricks-claude-opus-4-1", name: "Opus", hasKey: true },
{ provider: "omnigent", id: "no-key-model", name: "NoKey", hasKey: false },
];
const pi = {
registerCommand() {},
@@ -2692,10 +2692,11 @@ const ctx = {
// external_model_change). ``getAvailable`` returns only auth-configured
// models (what the picker should show); ``getAll`` is Pi's full built-in
// catalog (the fallback for older Pi).
model: { id: "databricks-claude-sonnet-4-6", name: "Sonnet" },
model: { provider: "omnigent", id: "databricks-claude-sonnet-4-6", name: "Sonnet" },
modelRegistry: {
getAll: () => catalog,
getAvailable: () => catalog.filter((m) => m.hasKey),
find: (provider, id) => catalog.find((m) => m.provider === provider && m.id === id),
},
};
@@ -2747,7 +2748,8 @@ def test_inbox_model_change_applies_via_set_model(tmp_path: Path) -> None:
+ r"""
(async () => {
await handlers.session_start({}, ctx); // starts the inbox poller
await deliverModelChange("databricks-claude-opus-4-1");
delete ctx.modelRegistry.find;
await deliverModelChange("omnigent/databricks-claude-opus-4-1");
assert.equal(setModelCalls.length, 1, JSON.stringify(setModelCalls));
assert.equal(setModelCalls[0].id, "databricks-claude-opus-4-1");
@@ -2814,23 +2816,23 @@ def test_model_select_mirrors_to_external_model_change(tmp_path: Path) -> None:
// resolves from the start; ignore that when checking the user switch.
const startupChanges = posted.filter((e) => e.type === "external_model_change");
assert.equal(startupChanges.length, 1, JSON.stringify(posted));
assert.equal(startupChanges[0].data.model, "databricks-claude-sonnet-4-6");
assert.equal(startupChanges[0].data.model, "omnigent/databricks-claude-sonnet-4-6");
// A genuine user switch mirrors back.
await handlers.model_select(
{ source: "set", model: { id: "databricks-claude-opus-4-1" } },
{ source: "set", model: { provider: "omnigent", id: "databricks-claude-opus-4-1" } },
ctx,
);
// A startup restore must be ignored (could clobber a pending web override).
await handlers.model_select(
{ source: "restore", model: { id: "databricks-claude-sonnet-4-6" } },
{ source: "restore", model: { provider: "omnigent", id: "databricks-claude-sonnet-4-6" } },
ctx,
);
const changes = posted.filter((e) => e.type === "external_model_change");
// Two total: the startup mirror + the one user switch (restore ignored).
assert.equal(changes.length, 2, JSON.stringify(posted));
assert.equal(changes[1].data.model, "databricks-claude-opus-4-1");
assert.equal(changes[1].data.model, "omnigent/databricks-claude-opus-4-1");
finish();
})().catch((error) => {
finish();
@@ -2870,7 +2872,10 @@ def test_session_start_posts_model_options_from_registry(tmp_path: Path) -> None
// getAvailable() filters out ``no-key-model`` (no configured auth).
assert.deepEqual(
models.map((m) => m.id),
["databricks-claude-sonnet-4-6", "databricks-claude-opus-4-1"],
[
"omnigent/databricks-claude-sonnet-4-6",
"omnigent/databricks-claude-opus-4-1",
],
JSON.stringify(models),
);
// Display name falls back to the model's ``name``.
@@ -2879,7 +2884,55 @@ def test_session_start_posts_model_options_from_registry(tmp_path: Path) -> None
// The launch model is mirrored so the pill/active-row resolve immediately.
const changes = posted.filter((e) => e.type === "external_model_change");
assert.equal(changes.length, 1, JSON.stringify(posted));
assert.equal(changes[0].data.model, "databricks-claude-sonnet-4-6");
assert.equal(changes[0].data.model, "omnigent/databricks-claude-sonnet-4-6");
finish();
})().catch((error) => {
finish();
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
"""
)
_run_extension_script(node, _extension_path(), script)
def test_models_without_provider_keep_bare_id_behavior(tmp_path: Path) -> None:
"""Older Pi model objects without ``provider`` still populate and mirror."""
node = shutil.which("node")
if node is None:
pytest.skip("node is required for the pi-native extension e2e test")
script = (
_MODEL_SWITCH_HARNESS
+ r"""
(async () => {
const legacyCatalog = catalog.map(({ provider: _provider, ...model }) => model);
const legacyCtx = {
...ctx,
model: { id: "databricks-claude-sonnet-4-6", name: "Sonnet" },
modelRegistry: {
getAll: () => legacyCatalog,
getAvailable: () => legacyCatalog.filter((model) => model.hasKey),
},
};
await handlers.session_start({}, legacyCtx);
const opts = posted.filter((event) => event.type === "external_model_options");
assert.deepEqual(
opts[0].data.models.map((model) => model.id),
["databricks-claude-sonnet-4-6", "databricks-claude-opus-4-1"],
);
assert.equal(opts[0].data.models[0].displayName, "Sonnet");
await handlers.model_select(
{ source: "set", model: { id: "databricks-claude-opus-4-1" } },
legacyCtx,
);
const changes = posted.filter((event) => event.type === "external_model_change");
assert.deepEqual(
changes.map((event) => event.data.model),
["databricks-claude-sonnet-4-6", "databricks-claude-opus-4-1"],
);
finish();
})().catch((error) => {
finish();
+3 -1
View File
@@ -127,10 +127,12 @@ export function ConfigRow({
label,
description,
children,
controlClassName,
}: {
label: string;
description?: string;
children: ReactNode;
controlClassName?: string;
}) {
return (
// Stacked on mobile (label above a full-width control) so the label never
@@ -141,7 +143,7 @@ export function ConfigRow({
<div className="text-ui font-medium">{label}</div>
{description && <div className="text-sm text-muted-foreground">{description}</div>}
</div>
<div className="w-full sm:w-52 sm:shrink-0">{children}</div>
<div className={cn("w-full sm:w-52 sm:shrink-0", controlClassName)}>{children}</div>
</div>
);
}
+3 -2
View File
@@ -17,7 +17,7 @@ export type NativeCodingAgentIconKind =
| "kimi"
| "hermes";
export type NativeCodingAgentCapability =
"permissionMode" | "approvalMode" | "cursorMode" | "skipPermissions";
"permissionMode" | "approvalMode" | "cursorMode" | "skipPermissions" | "modelPicker";
export interface NativeCodingAgentSpec {
key: NativeCodingAgentIconKind;
@@ -53,7 +53,7 @@ export const NATIVE_CODING_AGENTS = [
displayName: "Claude Code",
iconKind: "claude",
sortRank: 10,
capabilities: ["permissionMode"],
capabilities: ["permissionMode", "modelPicker"],
fullySupported: true,
},
{
@@ -106,6 +106,7 @@ export const NATIVE_CODING_AGENTS = [
displayName: "Pi",
iconKind: "pi",
sortRank: 40,
capabilities: ["modelPicker"],
},
{
key: "kiro",
+65 -1
View File
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { authenticatedFetch } from "@/lib/identity";
import type { Host } from "@/hooks/useHosts";
import { useHosts } from "@/hooks/useHosts";
import { useHostModelOptions, useHosts } from "@/hooks/useHosts";
import type { AvailableAgent } from "@/hooks/useAvailableAgents";
import { useAvailableAgents } from "@/hooks/useAvailableAgents";
import { NewChatLandingScreen, resetLandingDraft, sanitizeInitialPrompt } from "./NewChatDialog";
@@ -265,6 +265,14 @@ beforeEach(() => {
// left behind by an unmounting test doesn't seed the next one.
resetLandingDraft();
localStorage.clear();
vi.mocked(useHostModelOptions).mockReturnValue({
data: [
{ id: "opus", displayName: "Opus" },
{ id: "sonnet", displayName: "Sonnet" },
{ id: "haiku", displayName: "Haiku" },
],
isLoading: false,
} as unknown as ReturnType<typeof useHostModelOptions>);
// Seed host_1's recent so the working directory pre-fills deterministically
// (the create body must carry SEEDED_WORKSPACE through).
localStorage.setItem(RECENT_KEY, JSON.stringify({ host_1: [SEEDED_WORKSPACE] }));
@@ -1047,6 +1055,62 @@ describe("NewChatLandingScreen create flow", () => {
expect(body.reasoning_effort).toBe("high");
});
it("rides an omni-setup model along to create for pi-native", async () => {
setAgents([
agent({
id: "ag_pi",
name: "pi-native-ui",
display_name: "Pi",
harness: "pi-native",
}),
]);
vi.mocked(useHostModelOptions).mockReturnValue({
data: [
{
id: "omnigent-openai/system.ai.gpt-5-6-sol",
model: "omnigent-openai/system.ai.gpt-5-6-sol",
displayName: "GPT 5.6 Sol",
},
{
id: "omnigent/databricks-claude-sonnet-4-6",
model: "omnigent/databricks-claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
},
],
isLoading: false,
} as unknown as ReturnType<typeof useHostModelOptions>);
vi.mocked(authenticatedFetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: "conv_pi" }),
} as unknown as Response);
renderLanding();
await waitForWorkspaceSeed();
openAgentConfig("ag_pi");
fireEvent.click(screen.getByTestId("new-chat-landing-config-model"));
const fullNameRow = document.querySelector(
'[data-model-id="omnigent-openai/system.ai.gpt-5-6-sol"]',
);
expect(fullNameRow).not.toBeNull();
expect(fullNameRow).toHaveAttribute("title", "GPT 5.6 Sol");
fireEvent.change(screen.getByTestId("new-chat-landing-config-model-search"), {
target: { value: "gpt sol" },
});
expect(screen.getByText("GPT 5.6 Sol")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.6")).toBeNull();
fireEvent.click(screen.getByText("GPT 5.6 Sol"));
saveConfig();
typeMessage("go");
fireEvent.click(screen.getByTestId("new-chat-landing-submit"));
await waitFor(() => expect(authenticatedFetch).toHaveBeenCalledTimes(1));
const [, init] = vi.mocked(authenticatedFetch).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.model_override).toBe("omnigent-openai/system.ai.gpt-5-6-sol");
expect(body.reasoning_effort).toBeUndefined();
expect(body.labels?.["omnigent.wrapper"]).toBe("pi-native-ui");
});
it("seeds the model + effort from the last pick for claude-native on a new session", async () => {
// A returning user's last model/effort pick for this harness is on record;
// the new session must auto-fill it and post it WITHOUT re-opening the
+156 -9
View File
@@ -46,6 +46,13 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
CLAUDE_NATIVE_EFFORTS,
ConfigRow,
@@ -1370,9 +1377,93 @@ export function AgentHarnessPicker({
);
}
function SearchableModelPicker({
value,
options,
loading,
onValueChange,
}: {
value: string;
options: readonly { id: string; displayName: string }[];
loading: boolean;
onValueChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedLabel =
value === MODEL_SELECT_DEFAULT
? "Default"
: (options.find((option) => option.id === value)?.displayName ?? value);
const select = (nextValue: string) => {
onValueChange(nextValue);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Model"
className="h-8 w-full justify-between gap-2 px-2.5 font-normal"
data-testid="new-chat-landing-config-model"
>
<span className="min-w-0 truncate">{selectedLabel}</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] overflow-hidden p-0"
>
<Command className="h-auto min-h-0">
<CommandInput
placeholder="Search models…"
data-testid="new-chat-landing-config-model-search"
/>
<CommandList
className="max-h-72 min-h-0 overflow-y-auto overscroll-contain"
onWheel={(event) => event.stopPropagation()}
>
<CommandItem
value={MODEL_SELECT_DEFAULT}
data-checked={value === MODEL_SELECT_DEFAULT}
onSelect={() => select(MODEL_SELECT_DEFAULT)}
>
Default
</CommandItem>
{options.map((option) => (
<CommandItem
key={option.id}
value={option.id}
keywords={[option.displayName]}
title={option.displayName}
data-model-id={option.id}
data-checked={value === option.id}
onSelect={() => select(option.id)}
>
<span className="min-w-0 truncate">{option.displayName}</span>
</CommandItem>
))}
{!loading && <CommandEmpty>No models found</CommandEmpty>}
{loading && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
Loading models
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/**
* Harness-configuration modal opened from the composer's gear icon. Shows the
* selected agent's run-config knobs — Claude: model / effort / permissions;
* Pi: model;
* Codex/OpenCode: approval mode (+ Codex's dangerous full-bypass opt-in);
* Cursor: exec mode; bundle agents: brain-harness override. On the fully-auto
* harness the router owns harness and model, so every harness-specific knob
@@ -1402,6 +1493,8 @@ function HarnessConfigModal({
claudeModelsLoading,
codexModelOptions,
codexModelsLoading,
piModelOptions,
piModelsLoading,
pickedEffort,
pickedHarness,
costControlMode,
@@ -1432,6 +1525,8 @@ function HarnessConfigModal({
claudeModelsLoading: boolean;
codexModelOptions: readonly Pick<NativeModelOption, "id" | "displayName" | "isDefault">[];
codexModelsLoading: boolean;
piModelOptions: readonly { id: string; displayName: string }[];
piModelsLoading: boolean;
pickedEffort: string;
pickedHarness: string | null;
costControlMode: CostControlMode;
@@ -1453,9 +1548,8 @@ function HarnessConfigModal({
const hasApproval = nativeAgentHasCapability(agent, "approvalMode");
const hasCursor = nativeAgentHasCapability(agent, "cursorMode");
const hasAgySkip = nativeAgentHasCapability(agent, "skipPermissions");
const hasModelPicker = nativeAgentHasCapability(agent, "modelPicker");
const isCodex = entryHarness === "codex-native";
const modelOptions = isCodex ? codexModelOptions : claudeModelOptions;
const modelsLoading = isCodex ? codexModelsLoading : claudeModelsLoading;
const brainDefault =
agent.harness != null && agent.harness in brainHarnessLabels ? agent.harness : null;
@@ -1553,6 +1647,9 @@ function HarnessConfigModal({
effort: draftEffort,
mode: draftPermission,
});
} else if (hasModelPicker) {
setPickedModel(draftModel);
if (entryHarness) writeHarnessOption(entryHarness, { model: draftModel });
} else if (hasApproval) {
if (isCodex) setPickedModel(draftModel);
setApprovalMode(draftApproval);
@@ -1607,7 +1704,10 @@ function HarnessConfigModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="new-chat-landing-config-modal">
<DialogContent
className={cn("sm:max-w-md", entryHarness === "pi-native" && "sm:max-w-xl")}
data-testid="new-chat-landing-config-modal"
>
<DialogHeader>
<DialogTitle>Configure {configTitleName}</DialogTitle>
<DialogDescription className="sr-only">
@@ -1616,6 +1716,17 @@ function HarnessConfigModal({
</DialogHeader>
<div className="flex flex-col gap-5 py-1">
{!autoRouting && hasModelPicker && !hasPermission && (
<ConfigRow label="Model" description="Underlying LLM" controlClassName="sm:w-80">
<SearchableModelPicker
value={modelValue}
options={piModelOptions}
loading={piModelsLoading}
onValueChange={onModelChange}
/>
</ConfigRow>
)}
{!autoRouting && hasPermission && (
<>
<ConfigRow label="Model" description="Underlying LLM">
@@ -1698,10 +1809,10 @@ function HarnessConfigModal({
defaultLabel={defaultModelLabel(codexModelOptions, displayModelId)}
contentClassName="[&_[data-slot=select-item]]:pl-2.5"
>
{modelsLoading && (
{codexModelsLoading && (
<div className="px-2.5 py-1 text-sm text-muted-foreground">Loading models</div>
)}
{!modelsLoading && modelOptions.length === 0 && (
{!codexModelsLoading && codexModelOptions.length === 0 && (
<div className="px-2.5 py-1 text-sm text-muted-foreground">
Models unavailable
</div>
@@ -2107,6 +2218,11 @@ export function NewChatLandingScreen() {
"codex-native",
!sandboxSelected,
);
const { data: hostPiModelOptions, isLoading: hostPiModelsLoading } = useHostModelOptions(
selectedHostId,
"pi-native",
!sandboxSelected,
);
const claudeModelOptions = useMemo(
() =>
sandboxSelected
@@ -2124,6 +2240,16 @@ export function NewChatLandingScreen() {
() => (sandboxSelected ? [] : (hostCodexModelOptions ?? [])),
[hostCodexModelOptions, sandboxSelected],
);
const piModelOptions = useMemo(
() =>
sandboxSelected
? []
: (hostPiModelOptions ?? []).map((option) => ({
id: option.id,
displayName: option.displayName ?? option.id,
})),
[hostPiModelOptions, sandboxSelected],
);
// Desktop-shell host status for THIS machine (null outside Electron), so the
// picker can tag the current machine and offer to auto-connect it.
const [desktopHost, setDesktopHost] = useState<HostIdentity | null>(null);
@@ -2659,15 +2785,16 @@ export function NewChatLandingScreen() {
: agentList.find((a) => a.id === effectiveAgentId),
[agentList, effectiveAgentId, pendingAgent],
);
const selectedNativeHarness = nativeCodingAgentForAvailableAgent(selectedAgent)?.harness ?? null;
const supportsPermissionMode = nativeAgentHasCapability(selectedAgent, "permissionMode");
const supportsApprovalMode = nativeAgentHasCapability(selectedAgent, "approvalMode");
const supportsCursorMode = nativeAgentHasCapability(selectedAgent, "cursorMode");
const supportsAgySkipPermissions = nativeAgentHasCapability(selectedAgent, "skipPermissions");
const supportsModelPicker = nativeAgentHasCapability(selectedAgent, "modelPicker");
const hideUnconfiguredHarnesses = useMemo(() => readHideUnconfiguredHarnesses(), []);
// The selected native harness, used to persist/seed its option knobs (mode /
// model / effort), which are harness-specific. null for non-native agents,
// which have no knobs to remember.
const selectedNativeHarness = nativeCodingAgentForAvailableAgent(selectedAgent)?.harness ?? null;
const selectedHost = allHosts.find((h) => h.host_id === selectedHostId);
// Warn-only readiness signal for the agent picker: only meaningful when
// a connected host is selected (a sandbox provisions its own tooling).
@@ -2705,6 +2832,7 @@ export function NewChatLandingScreen() {
supportsApprovalMode ||
supportsCursorMode ||
supportsAgySkipPermissions ||
supportsModelPicker ||
smartRoutingEligible ||
(selectedAgent?.harness != null && selectedAgent.harness in brainHarnessLabelsAll);
// Label/value pairs summarizing the selected agent's current run-config, for
@@ -2731,6 +2859,11 @@ export function NewChatLandingScreen() {
// previously selected native harness.
return [{ label: "Permissions", value: AUTO_PERMISSION_MODE.label }];
}
if (supportsModelPicker && !supportsPermissionMode) {
const modelValue =
piModelOptions.find((model) => model.id === pickedModel)?.displayName ?? "Default";
return [{ label: "Model", value: modelValue }];
}
if (supportsPermissionMode) {
const modelValue = routingOn
? SMART_ROUTING_LABEL
@@ -2804,12 +2937,14 @@ export function NewChatLandingScreen() {
supportsApprovalMode,
supportsCursorMode,
supportsAgySkipPermissions,
supportsModelPicker,
selectedAgent,
brainHarnessLabelsAll,
routingOn,
pickedModel,
claudeModelOptions,
codexModelOptions,
piModelOptions,
pickedEffort,
permissionMode,
approvalMode,
@@ -2859,6 +2994,13 @@ export function NewChatLandingScreen() {
// this holds on every run of this effect — including the re-run when the
// model catalog resolves, which lands after the routing seed below.
const storedRoutingOn = stored.routing === "on";
if (selectedNativeHarness === "pi-native") {
setPickedModel(
stored.model != null && piModelOptions.some((model) => model.id === stored.model)
? stored.model
: "",
);
}
if (supportsPermissionMode) {
setPermissionMode(
resolve(CLAUDE_NATIVE_PERMISSION_MODES, CLAUDE_NATIVE_DEFAULT_PERMISSION_MODE),
@@ -2903,7 +3045,7 @@ export function NewChatLandingScreen() {
// Reseed on harness changes and when the selected host's catalog resolves;
// capability flags are derived from the same harness and stay omitted.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNativeHarness, claudeModelOptions, codexModelOptions]);
}, [selectedNativeHarness, claudeModelOptions, codexModelOptions, piModelOptions]);
// Smart Routing is remembered per harness alongside the mode/model
// knobs, in its own effect because eligibility depends on the server flag
// (which resolves after mount — this must reseed when it lands). A stored
@@ -3618,6 +3760,7 @@ export function NewChatLandingScreen() {
const agentSupportsApprovalMode = nativeAgentHasCapability(agent, "approvalMode");
const agentSupportsCursorMode = nativeAgentHasCapability(agent, "cursorMode");
const agentSupportsAgySkip = nativeAgentHasCapability(agent, "skipPermissions");
const agentSupportsModelPicker = nativeAgentHasCapability(agent, "modelPicker");
// Smart Routing — server-side. The fully-auto harness always routes
// (harness + model), so send "on" to keep the persisted state consistent
// with the lit routing icon. Otherwise only send it when routing is
@@ -3785,13 +3928,13 @@ export function NewChatLandingScreen() {
? (AGY_NATIVE_SKIP_MODES.find((m) => m.value === agySkipMode)?.args ?? [])
: undefined,
// Model + reasoning effort, persisted on the session row before
// the runner launches. Claude and Codex read model_override at
// the runner launches. Claude, Codex, and Pi read model_override at
// terminal launch; an unselected ("") knob is omitted so the
// harness keeps its own configured/default model.
model_override:
!smartRoutingHarnessSelected &&
!routingOwnsModel &&
(agentSupportsPermissionMode || nativeAgent?.harness === "codex-native") &&
(agentSupportsModelPicker || nativeAgent?.harness === "codex-native") &&
pickedModel
? pickedModel
: undefined,
@@ -4363,6 +4506,10 @@ export function NewChatLandingScreen() {
codexModelsLoading={
!sandboxSelected && selectedHostId !== null && hostCodexModelsLoading
}
piModelOptions={piModelOptions}
piModelsLoading={
!sandboxSelected && selectedHostId !== null && hostPiModelsLoading
}
pickedEffort={pickedEffort}
pickedHarness={pickedHarness}
costControlMode={costControlMode}