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
67 changes: 67 additions & 0 deletions src/features/sessions/ui/ModelPicker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,73 @@ describe("model picker", () => {
expect(container.querySelectorAll('[role="option"]')).toHaveLength(2);
});

it("groups omp models by provider in keyboard order", () => {
// omp sorts by model name, so providers arrive interleaved.
setHarnessModels("omp", [
{
id: "omp:anthropic/claude-sonnet-5",
harness: "omp",
name: "Claude Sonnet 5",
nativeId: "anthropic/claude-sonnet-5",
provider: { id: "anthropic", name: "Anthropic" },
},
{
id: "omp:github-copilot/claude-sonnet-5",
harness: "omp",
name: "Claude Sonnet 5",
nativeId: "github-copilot/claude-sonnet-5",
provider: { id: "github-copilot", name: "GitHub Copilot" },
},
{
id: "omp:anthropic/claude-opus-5",
harness: "omp",
name: "Claude Opus 5",
nativeId: "anthropic/claude-opus-5",
provider: { id: "anthropic", name: "Anthropic" },
},
]);
const onChange = vi.fn();

act(() =>
root.render(
createElement(ModelPicker, {
harness: "omp",
model: "omp:anthropic/claude-sonnet-5",
values: {},
hideSettings: true,
onChange,
onSettingsChange: vi.fn(),
}),
),
);

act(() =>
container
.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')!
.click(),
);
expect(
[...container.querySelectorAll('[role="group"]')].map((group) =>
group.getAttribute("aria-label"),
),
).toEqual(["Anthropic", "GitHub Copilot"]);
const options = [
...container.querySelectorAll<HTMLButtonElement>('[role="option"]'),
];
expect(options.map((option) => option.getAttribute("aria-label"))).toEqual(
[
"Claude Sonnet 5, Anthropic",
"Claude Opus 5, Anthropic",
"Claude Sonnet 5, GitHub Copilot",
],
);
// The next row down is the next visible row, not the next catalog entry.
options[0].focus();
keyDown(options[0], "ArrowDown");
keyDown(options[0], "Enter");
expect(onChange).toHaveBeenCalledWith("omp", "omp:anthropic/claude-opus-5");
});

it("names the source of same-name favorites from different providers", () => {
setHarnessModels("cursor", [
{
Expand Down
49 changes: 41 additions & 8 deletions src/features/sessions/ui/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,38 @@ function recentMenuModels(
return models.slice(0, 6);
}

/** Harness tabs whose catalog names upstream providers list models under them. */
function groupsByProvider(tab: ModelPickerTab, models: AgentModel[]): boolean {
return tab !== "favorites" && models.some((item) => item.provider);
}

/** Use the upstream provider when present, otherwise the model's harness. */
function modelProvider(item: AgentModel): { id: string; name: string } {
return item.provider ?? { id: item.harness, name: HARNESS_TITLE[item.harness] };
}

/**
* Keeps each provider's models contiguous (in first-seen provider order) so
* keyboard navigation, which walks this list, matches the grouped headings.
*/
function inProviderOrder(
tab: ModelPickerTab,
models: AgentModel[],
): AgentModel[] {
if (!groupsByProvider(tab, models)) return models;
const groups = new Map<string, AgentModel[]>();
for (const item of models) {
const id = modelProvider(item).id;
const group = groups.get(id);
if (group) group.push(item);
else groups.set(id, [item]);
}
return [...groups.values()].flat();
}

/** Build provider headings while retaining each model's keyboard navigation index. */
function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {
if (tab !== "opencode") {
if (!groupsByProvider(tab, models)) {
return [
{
id: "models",
Expand All @@ -251,7 +281,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {

const groups = new Map<string, ModelGroup>();
models.forEach((item, index) => {
const provider = item.provider ?? { id: "opencode", name: "OpenCode" };
const provider = modelProvider(item);
let group = groups.get(provider.id);
if (!group) {
group = { id: provider.id, name: provider.name, models: [] };
Expand All @@ -262,6 +292,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] {
return [...groups.values()];
}

/** Choose a harness model and its settings, with favorites and provider groups. */
export function ModelPicker({
harness,
model,
Expand Down Expand Up @@ -389,12 +420,14 @@ export function ModelPicker({
item != null && pickerHarnesses.includes(item.harness),
)
: source.modelsFor(visibleTab);
if (!needle) return pool;
return pool.filter((item) =>
`${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""}`
.toLowerCase()
.includes(needle),
);
const matches = needle
? pool.filter((item) =>
`${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""}`
.toLowerCase()
.includes(needle),
)
: pool;
return inProviderOrder(visibleTab, matches);
}, [source, catalogVersion, favorites, providerKey, query, visibleTab]);

const dismiss = (restore: boolean) => {
Expand Down
33 changes: 33 additions & 0 deletions src/integrations/harness/core/providerNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Display names for upstream providers that multi-provider harnesses
// (OpenCode, Pi/omp, Hermes) report only as slugs.
const PROVIDER_NAMES: Record<string, string> = {
anthropic: "Anthropic",
deepseek: "DeepSeek",
"github-copilot": "GitHub Copilot",
"google-antigravity": "Antigravity",
"google-gemini-cli": "Gemini CLI",
"lm-studio": "LM Studio",
ollama: "Ollama",
openai: "OpenAI",
"openai-codex": "OpenAI Codex",
opencode: "OpenCode",
"opencode-go": "OpenCode Go",
openrouter: "OpenRouter",
xai: "xAI",
};

/** Resolve a provider slug to its display name, title-casing unknown providers. */
export function upstreamProviderName(providerID: string): string {
return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID);
}

/** Capitalize slug segments separated by hyphens, underscores, or slashes. */
export function titleCaseSlug(value: string): string {
const segments: string[] = [];
for (const segment of value.split(/[-_/]+/)) {
if (segment.length > 0) {
segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
}
}
return segments.join(" ");
}
30 changes: 26 additions & 4 deletions src/integrations/harness/providers/hermes/hermesProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,22 @@ describe("Hermes ACP protocol", () => {
models: {
currentModelId: "nous:hermes-4",
availableModels: [
{ modelId: "openrouter:gpt-5", name: "OpenRouter · GPT-5" },
{ modelId: "nous:hermes-4", name: "Nous · Hermes 4" },
{
modelId: "openrouter:gpt-5",
name: "OpenRouter · gpt-5",
description: "Provider: OpenRouter",
},
{
modelId: "nous:hermes-4",
name: "Nous Portal · hermes-4",
description: "Provider: Nous Portal • current",
},
{ modelId: "nous:hermes-4", name: "duplicate" },
{
modelId: "custom:ollama:llama3:8b",
name: "llama3:8b",
description: "Provider: Ollama • Local • current",
},
],
},
};
Expand All @@ -90,14 +103,23 @@ describe("Hermes ACP protocol", () => {
{
id: "hermes:nous:hermes-4",
harness: "hermes",
name: "Nous · Hermes 4",
name: "hermes-4",
nativeId: "nous:hermes-4",
provider: { id: "nous", name: "Nous Portal" },
},
{
id: "hermes:openrouter:gpt-5",
harness: "hermes",
name: "OpenRouter · GPT-5",
name: "gpt-5",
nativeId: "openrouter:gpt-5",
provider: { id: "openrouter", name: "OpenRouter" },
},
{
id: "hermes:custom:ollama:llama3:8b",
harness: "hermes",
name: "llama3:8b",
nativeId: "custom:ollama:llama3:8b",
provider: { id: "custom:ollama", name: "Ollama" },
},
]);
});
Expand Down
40 changes: 39 additions & 1 deletion src/integrations/harness/providers/hermes/hermesProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { promptBlocks, type PromptContentBlock } from "../../../../features/sessions/model/attachments";
import type { AgentModel } from "../../../../features/sessions/model/models";
import type { Attachment, RuntimeMode } from "../../../../features/sessions/model/session";
import { upstreamProviderName } from "../../core/providerNames";

export type HermesBackgroundDispatch = {
callId: string;
Expand Down Expand Up @@ -146,12 +147,15 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] {
).trim();
if (!nativeId || seen.has(nativeId)) continue;
seen.add(nativeId);
const name = String(model.name ?? model.title ?? nativeId).trim();
const rawName = String(model.name ?? model.title ?? nativeId).trim();
const provider = hermesModelProvider(nativeId, rawName, model.description);
const name = provider ? stripProviderPrefix(rawName, provider.name) : rawName;
models.push({
id: `hermes:${nativeId}`,
harness: "hermes",
name: name || displayName(nativeId),
nativeId,
...(provider ? { provider } : {}),
});
}

Expand All @@ -164,6 +168,40 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] {
return models;
}

/**
* Hermes encodes picker ids as `provider:model` (`custom:<key>:model` for
* user-defined endpoints) and labels rows `Provider: <name>` in their
* description, with the inventory rows also named `<name> · <model>`.
*/
function hermesModelProvider(
nativeId: string,
name: string,
description: unknown,
): AgentModel["provider"] {
const parts = nativeId.split(":");
if (parts.length < 2) return undefined;
const id = parts[0] === "custom" && parts.length > 2
? `${parts[0]}:${parts[1]}`
: parts[0];
const described =
typeof description === "string"
? /^Provider:\s*([^•]+)/.exec(description)?.[1]?.trim()
: undefined;
const prefixed = name.includes(" · ")
? name.slice(0, name.indexOf(" · ")).trim()
: undefined;
return {
id,
name: described || prefixed || upstreamProviderName(id.replace(/^custom:/, "")),
};
}

/** Remove a matching provider label now shown by the picker's group heading. */
function stripProviderPrefix(name: string, provider: string): string {
const prefix = `${provider} · `;
return name.startsWith(prefix) ? name.slice(prefix.length).trim() : name;
}

function displayName(nativeId: string): string {
const slug = nativeId.includes(":")
? nativeId.slice(nativeId.lastIndexOf(":") + 1)
Expand Down
12 changes: 3 additions & 9 deletions src/integrations/harness/providers/opencode/opencodeCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
sortOpenCodeVariants,
titleCaseSlug,
} from "./opencodeProtocol";
import { upstreamProviderName } from "../../core/providerNames";

const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;
const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/;
Expand All @@ -34,14 +35,6 @@ type ParsedProvider = {
models: Record<string, OpenCodeModelJson>;
};

const PROVIDER_NAMES: Record<string, string> = {
opencode: "OpenCode",
"opencode-go": "OpenCode Go",
openai: "OpenAI",
xai: "xAI",
"github-copilot": "GitHub Copilot",
};

export type OpenCodeAgent = {
name: string;
mode: string;
Expand Down Expand Up @@ -221,8 +214,9 @@ export function flattenOpenCodeModels(
return models.sort((left, right) => left.name.localeCompare(right.name));
}

/** Resolve OpenCode provider slugs using the shared upstream display names. */
export function openCodeProviderName(providerID: string): string {
return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID);
return upstreamProviderName(providerID);
}

function openCodeModelSettings(
Expand Down
11 changes: 2 additions & 9 deletions src/integrations/harness/providers/opencode/opencodeProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../../../../features/sessions/model/attachments";
import { isTaskListToolName } from "../../../../features/sessions/model/taskList";
import { extractToolPreview } from "../../core/preview";
import { titleCaseSlug } from "../../core/providerNames";
import type { HarnessEvent } from "../../core/types";

export const MINIMUM_OPENCODE_VERSION = "1.14.19";
Expand Down Expand Up @@ -242,15 +243,7 @@ function commonPrefixLength(left: string, right: string): number {
return index;
}

export function titleCaseSlug(value: string): string {
const segments: string[] = [];
for (const segment of value.split(/[-_/]+/)) {
if (segment.length > 0) {
segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
}
}
return segments.join(" ");
}
export { titleCaseSlug };

export function inferDefaultVariant(
providerID: string,
Expand Down
4 changes: 4 additions & 0 deletions src/integrations/harness/providers/pi/piProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ describe("tools and models", () => {
]);
expect(models[0]?.settings?.[0]?.id).toBe("thinking");
expect(models[0]?.contextWindow).toBe(200000);
expect(models.map((model) => model.provider)).toEqual([
{ id: "anthropic", name: "Anthropic" },
{ id: "openai", name: "OpenAI" },
]);
expect(models[1]?.settings).toBeUndefined();
});

Expand Down
3 changes: 3 additions & 0 deletions src/integrations/harness/providers/pi/piProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AgentModel, ModelSetting } from "../../../../features/sessions/mod
import { isTaskListToolName } from "../../../../features/sessions/model/taskList";
import type { PiFlavor } from "./piFlavor";
import { extractToolPreview, titleFromToolInput } from "../../core/preview";
import { upstreamProviderName } from "../../core/providerNames";
import { streamTextDelta } from "../../core/streamText";

/** Images Pi RPC accepts on `prompt` / `steer`. */
Expand Down Expand Up @@ -739,6 +740,7 @@ export function isHiddenCopilotModel(provider: string, modelId: string) {
);
}

/** Build the Pi/omp picker catalog with provider labels, sorted by model name. */
export function modelsFromRpcData(
flavor: PiFlavor,
data: unknown,
Expand Down Expand Up @@ -772,6 +774,7 @@ export function modelsFromRpcData(
harness: flavor.id,
name,
nativeId,
provider: { id: provider, name: upstreamProviderName(provider) },
...(settings.length > 0 ? { settings } : {}),
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
});
Expand Down
Loading