diff --git a/opencode/packages/console/app/src/routes/workspace/[id]/model-section.module.css b/opencode/packages/console/app/src/routes/workspace/[id]/model-section.module.css index fa76848..20c5a4d 100644 --- a/opencode/packages/console/app/src/routes/workspace/[id]/model-section.module.css +++ b/opencode/packages/console/app/src/routes/workspace/[id]/model-section.module.css @@ -171,3 +171,58 @@ } } } + +.providerForm { + display: flex; + align-items: center; + gap: var(--space-3); + font-family: var(--font-sans); + + select { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-background); + color: var(--color-text); + font-size: var(--font-size-sm); + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: var(--color-border-hover); + } + } +} + +.fallbackLabel { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + cursor: pointer; + white-space: nowrap; + + input[type="checkbox"] { + width: auto; + margin: 0; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + span { + cursor: inherit; + } + + &:has(input:disabled) { + cursor: not-allowed; + } +} diff --git a/opencode/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/opencode/packages/console/app/src/routes/workspace/[id]/model-section.tsx index 97f9527..4afa943 100644 --- a/opencode/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/opencode/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -1,8 +1,7 @@ -import { Model } from "@opencode-ai/console-core/model.js" +import { Model, ZenData, ProviderPreference } from "@opencode-ai/console-core/model.js" import { query, action, useParams, createAsync, json } from "@solidjs/router" -import { createMemo, For, Show } from "solid-js" +import { createMemo, For, Show, createSignal, createEffect } from "solid-js" import { withActor } from "~/context/auth.withActor" -import { ZenData } from "@opencode-ai/console-core/model.js" import styles from "./model-section.module.css" import { querySessionInfo } from "../common" import { @@ -20,6 +19,18 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formError } from "~/lib/form-error" +const OPENROUTER_PROVIDERS = [ + { value: "", label: "Auto (default)" }, + { value: "anthropic", label: "Anthropic only" }, + { value: "openai", label: "OpenAI only" }, + { value: "google", label: "Google only" }, + { value: "together", label: "Together AI only" }, + { value: "fireworks", label: "Fireworks AI only" }, + { value: "groq", label: "Groq only" }, + { value: "deepinfra", label: "DeepInfra only" }, + { value: "perplexity", label: "Perplexity only" }, +] + const getModelLab = (modelId: string) => { if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("gpt")) return "OpenAI" @@ -35,8 +46,10 @@ const getModelLab = (modelId: string) => { const getModelsInfo = query(async (workspaceID: string) => { "use server" return withActor(async () => { + const zenData = ZenData.list() + const preferences = await Model.listProviderPreferences() return { - all: Object.entries(ZenData.list().models) + all: Object.entries(zenData.models) .filter(([id, _model]) => !["claude-3-5-haiku"].includes(id)) .filter(([id, _model]) => !id.startsWith("alpha-")) .sort(([idA, modelA], [idB, modelB]) => { @@ -53,7 +66,16 @@ const getModelsInfo = query(async (workspaceID: string) => { const modelBName = Array.isArray(modelB) ? modelB[0].name : modelB.name return modelAName.localeCompare(modelBName) }) - .map(([id, model]) => ({ id, name: Array.isArray(model) ? model[0].name : model.name })), + .map(([id, model]) => { + const modelData = Array.isArray(model) ? model[0] : model + const hasOpenRouter = modelData.providers.some((p) => p.id === "openrouter") + return { + id, + name: modelData.name, + hasOpenRouter, + providerPreference: preferences[id], + } + }), disabled: await Model.listDisabled(), } }, workspaceID) @@ -78,6 +100,33 @@ const updateModel = action(async (form: FormData) => { ) }, "model.toggle") +const updateProviderPreference = action(async (form: FormData) => { + "use server" + const model = form.get("model")?.toString() + if (!model) return { error: formError.modelRequired } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: formError.workspaceRequired } + const providerValue = form.get("provider")?.toString() + const allowFallbacks = form.get("allowFallbacks")?.toString() === "true" + + return json( + withActor(async () => { + if (!providerValue) { + await Model.deleteProviderPreference({ model }) + } else { + await Model.setProviderPreference({ + model, + preference: { + order: [providerValue], + allow_fallbacks: allowFallbacks, + }, + }) + } + }, workspaceID), + { revalidate: getModelsInfo.key }, + ) +}, "model.providerPreference") + export function ModelSection() { const params = useParams() const i18n = useI18n() @@ -110,13 +159,16 @@ export function ModelSection() {