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
14 changes: 7 additions & 7 deletions plugins/web-ui/src/model-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,8 @@ function buildOption(
): ModelOption | null {
try {
const dynamic = catalog[id];
const meta = MODEL_CATALOG[id] ?? (dynamic ? { label: dynamic.name, buttonLabel: dynamic.name } : null);
if (!meta) return null;
const model = getBaseModel(id, dynamic);
const meta = MODEL_CATALOG[id] ?? (dynamic ? { label: dynamic.name, buttonLabel: dynamic.name } : { label: id, buttonLabel: id });
const model = getBaseModel(id, dynamic ?? { name: id, provider: "custom" });
return {
value: qualified ? `${harnessId}:${id}` : id,
harnessId,
Expand Down Expand Up @@ -157,10 +156,11 @@ export function runtimeModelOptions(
catalog: Readonly<Record<string, { name: string; provider: string }>> = {},
): ModelOption[] {
const options = approvedHarnesses.flatMap((harnessId) => {
const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog);
return configured.length
? configured
: buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog);
const serverModels = modelsByHarness[harnessId];
if (serverModels) {
return buildOptions(serverModels, harnessId, true, catalog);
}
return buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog);
});
return options.length ? options : buildOptions(DEFAULT_PICKER_MODEL_IDS);
}
Expand Down
7 changes: 5 additions & 2 deletions plugins/web-ui/src/pi-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ export function getBaseModel(id: string, fallback?: { name: string; provider: st
const template = builtinModel(clone.template);
if (template) return cloneModel(template, id, clone.name);
}
if (fallback?.provider === "openrouter") {
const template = getModel("openrouter", "openrouter/auto" as Parameters<typeof getModel>[1]) as PiModel | undefined;
if (fallback) {
const providerKey = (KNOWN_PROVIDERS.includes(fallback.provider as any) ? fallback.provider : "openai") as any;
const template = (getModel(providerKey, "gpt-4o" as any) ?? getModel("openai", "gpt-4o" as any)) as PiModel | undefined;
if (template) return cloneModel(template, id, fallback.name);
}
const defaultTemplate = getModel("openai", "gpt-4o" as any) as PiModel | undefined;
if (defaultTemplate) return cloneModel(defaultTemplate, id, id);
throw new Error(`Unsupported model: ${id}`);
}

Expand Down
4 changes: 3 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,11 +1034,13 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise<void> {
...(mark ? { mark } : {}),
...(selfLabel ? { selfLabel } : {}),
};
const customCount = deps.customProviders ? (await deps.customProviders.enabled()).length : 0;
const isConfigured = (managedKeys ? Object.values(managedKeys).some(Boolean) : false) || customCount > 0;
return sendJson(res, 200, {
webuiModels: configuredPicker.length ? configuredPicker : allowed,
baseModel: resolvedBase,
harnessId,
...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}),
modelProviderConfigured: isConfigured,
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
Expand Down
4 changes: 3 additions & 1 deletion src/core/turn-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { customModelCatalog } from "../model/custom-providers.ts";
import {
DEFAULT_WEBUI_MODEL_IDS,
THINKING_LEVELS,
Expand Down Expand Up @@ -49,7 +50,8 @@ export function validateWebTurnModelOptions(
enabledModels: readonly string[] | null,
providers: ModelProviderAvailability = ALL_PROVIDERS_AVAILABLE,
): string | null {
const enabled = enabledModels?.length ? enabledModels : DEFAULT_WEBUI_MODEL_IDS;
const customIds = customModelCatalog().map((m) => m.id);
const enabled = enabledModels?.length ? enabledModels : [...DEFAULT_WEBUI_MODEL_IDS, ...customIds];
const allowedModels = serviceableModelIds(enabled, providers);
if (input.model && !allowedModels.includes(input.model)) {
return resolveModel(input.model) && !modelServiceable(input.model, providers)
Expand Down
2 changes: 2 additions & 0 deletions src/model/custom-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface CustomRuntimeModel {
id: string;
name: string;
provider: string;
protocol?: CustomProviderProtocol;
api: "openai-completions" | "anthropic-messages";
baseUrl: string;
reasoning: boolean;
Expand All @@ -102,6 +103,7 @@ function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): Custo
id: m.id,
name: m.name?.trim() || m.id,
provider: provider.id,
protocol: provider.protocol,
api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions",
baseUrl: provider.baseUrl,
reasoning: false,
Expand Down
43 changes: 31 additions & 12 deletions src/model/pi-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,19 @@ export function contextTokenBudgetForModel(id: string): number | undefined {

export function modelSupportedByHarness(id: string | undefined, harness: string): boolean {
if (!id) return false;
if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id))
return harness === "pi" || harness === "opencode" || harness === "mock";
if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) {
const custom = resolveCustomModel(id);
if (harness === "pi" || harness === "opencode" || harness === "mock") return true;
if (harness === "claude") return custom?.protocol === "anthropic" || /^claude-/i.test(id);
if (harness === "codex") return custom?.protocol === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id);
return true;
}
if (harness === "pi" || harness === "opencode" || harness === "mock") return Boolean(resolveModel(id));
const provider = resolveModel(id)?.provider;
if (harness === "claude") return provider === "anthropic" || /^claude-/i.test(id);
if (harness === "codex") return provider === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id);
const model = resolveModel(id);
const provider = model?.provider;
const protocol = (model as unknown as { protocol?: string })?.protocol;
if (harness === "claude") return provider === "anthropic" || protocol === "anthropic" || /^claude-/i.test(id);
if (harness === "codex") return provider === "openai" || protocol === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id);
return false;
}

Expand All @@ -205,11 +212,12 @@ export interface ModelProviderAvailability {
}

export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean {
const provider = resolveModel(id)?.provider;
if (!provider) return false;
const model = resolveModel(id);
if (!model) return false;
const provider = model.provider;
if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) return true;
if (provider === "openai") return providers.openai;
if (provider === "anthropic") return providers.anthropic;
if (provider === "openai") return providers.openai || Boolean(process.env.OPENAI_BASE_URL);
if (provider === "anthropic") return providers.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL);
if (provider === "openrouter") return providers.openrouter;
return true;
}
Expand All @@ -225,9 +233,20 @@ export function modelProviderAvailabilityFor(
configKeys: ModelProviderAvailability,
managedKeys: ModelProviderAvailability = configKeys,
): ModelProviderAvailability {
if (harness === "pi") return managedKeys;
if (harness === "opencode") return { ...configKeys, openrouter: false };
if (harness === "codex") return configKeys;
const effectiveConfig = {
...configKeys,
openai: configKeys.openai || Boolean(process.env.OPENAI_BASE_URL),
anthropic: configKeys.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL),
};
const effectiveManaged = {
...managedKeys,
openai: managedKeys.openai || Boolean(process.env.OPENAI_BASE_URL),
anthropic: managedKeys.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL),
};
if (harness === "pi") return effectiveManaged;
if (harness === "opencode") return { ...effectiveConfig, openrouter: false };
if (harness === "codex") return effectiveConfig;
if (harness === "claude") return effectiveConfig;
return ALL_PROVIDERS_AVAILABLE;
}

Expand Down
2 changes: 1 addition & 1 deletion test/custom-provider-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async ()
assert.equal((model as { baseUrl?: string }).baseUrl, upstreamUrl);
assert.equal(modelSupportedByHarness("qa-chat", "pi"), true);
assert.equal(modelSupportedByHarness("qa-chat", "opencode"), true);
assert.equal(modelSupportedByHarness("qa-chat", "codex"), false);
assert.equal(modelSupportedByHarness("qa-chat", "codex"), true);
assert.equal(modelServiceable("qa-chat", { anthropic: false, openai: false, openrouter: false }), true);

// 6. REAL model call through QM's pi path → fake upstream answers
Expand Down