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() { {i18n.t("workspace.models.table.model")} + Provider {i18n.t("workspace.models.table.enabled")} - {({ id, name, lab }) => { + {({ id, name, lab, hasOpenRouter, providerPreference }) => { const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(id)) + const currentProvider = () => providerPreference?.order?.[0] ?? "" + const allowFallbacks = () => providerPreference?.allow_fallbacks ?? true return ( @@ -147,6 +199,43 @@ export function ModelSection() { {lab} + + +
+ + + + +
+
+
diff --git a/opencode/packages/console/app/src/routes/zen/util/handler.ts b/opencode/packages/console/app/src/routes/zen/util/handler.ts index 91fa306..140cbdf 100644 --- a/opencode/packages/console/app/src/routes/zen/util/handler.ts +++ b/opencode/packages/console/app/src/routes/zen/util/handler.ts @@ -8,11 +8,12 @@ import { Identifier } from "@opencode-ai/console-core/identifier.js" import { Billing } from "@opencode-ai/console-core/billing.js" import { Actor } from "@opencode-ai/console-core/actor.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" -import { ZenData } from "@opencode-ai/console-core/model.js" +import { ZenData, Model, ProviderPreference } from "@opencode-ai/console-core/model.js" import { Black, BlackData } from "@opencode-ai/console-core/black.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" +import { ModelProviderPreferenceTable } from "@opencode-ai/console-core/schema/model-provider-preference.sql.js" import { logger } from "./logger" import { AuthError, @@ -85,6 +86,7 @@ export async function handler( const stickyProvider = await stickyTracker?.get() const authInfo = await authenticate(modelInfo) const billingSource = validateBilling(authInfo, modelInfo) + const providerPreference = await getProviderPreference(authInfo, modelInfo.id) const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const providerInfo = selectProvider( @@ -107,6 +109,14 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, + ...(providerInfo.id === "openrouter" && providerPreference && { + provider: { + ...(providerPreference.order && { order: providerPreference.order }), + ...(providerPreference.only && { only: providerPreference.only }), + ...(providerPreference.ignore && { ignore: providerPreference.ignore }), + allow_fallbacks: providerPreference.allow_fallbacks ?? true, + }, + }), }), ) logger.debug("REQUEST URL: " + reqUrl) @@ -589,6 +599,29 @@ export async function handler( providerInfo.apiKey = authInfo.provider.credentials } + async function getProviderPreference(authInfo: AuthInfo, modelId: string): Promise { + if (!authInfo) return undefined + try { + const result = await Database.use((db) => + db + .select({ preference: ModelProviderPreferenceTable.provider_preference }) + .from(ModelProviderPreferenceTable) + .where( + and( + eq(ModelProviderPreferenceTable.workspaceID, authInfo.workspaceID), + eq(ModelProviderPreferenceTable.model, modelId), + ), + ) + .limit(1), + ) + + if (result.length === 0) return undefined + return JSON.parse(result[0].preference) as ProviderPreference + } catch { + return undefined + } + } + async function trackUsage( authInfo: AuthInfo, modelInfo: ModelInfo, diff --git a/opencode/packages/console/core/src/model.ts b/opencode/packages/console/core/src/model.ts index 831b3c5..29eb391 100644 --- a/opencode/packages/console/core/src/model.ts +++ b/opencode/packages/console/core/src/model.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { eq, and } from "drizzle-orm" import { Database } from "./drizzle" import { ModelTable } from "./schema/model.sql" +import { ModelProviderPreferenceTable } from "./schema/model-provider-preference.sql" import { Identifier } from "./identifier" import { fn } from "./util/fn" import { Actor } from "./actor" @@ -91,6 +92,14 @@ export namespace ZenData { }) } +export const ProviderPreferenceSchema = z.object({ + order: z.array(z.string()).optional(), + only: z.array(z.string()).optional(), + ignore: z.array(z.string()).optional(), + allow_fallbacks: z.boolean().optional(), +}) +export type ProviderPreference = z.infer + export namespace Model { export const enable = fn(z.object({ model: z.string() }), ({ model }) => { Actor.assertAdmin() @@ -143,4 +152,77 @@ export namespace Model { }) }, ) + + export const setProviderPreference = fn( + z.object({ + model: z.string(), + preference: ProviderPreferenceSchema, + }), + ({ model, preference }) => { + Actor.assertAdmin() + return Database.use((db) => + db + .insert(ModelProviderPreferenceTable) + .values({ + id: Identifier.create("model_provider_pref"), + workspaceID: Actor.workspace(), + model: model, + provider_preference: JSON.stringify(preference), + }) + .onDuplicateKeyUpdate({ + set: { + provider_preference: JSON.stringify(preference), + timeDeleted: null, + }, + }), + ) + }, + ) + + export const getProviderPreference = fn( + z.object({ model: z.string() }), + ({ model }) => { + return Database.use(async (db) => { + const result = await db + .select({ preference: ModelProviderPreferenceTable.provider_preference }) + .from(ModelProviderPreferenceTable) + .where(and(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace()), eq(ModelProviderPreferenceTable.model, model))) + .limit(1) + + if (result.length === 0) return undefined + try { + return ProviderPreferenceSchema.parse(JSON.parse(result[0].preference)) + } catch { + return undefined + } + }) + }, + ) + + export const deleteProviderPreference = fn( + z.object({ model: z.string() }), + ({ model }) => { + Actor.assertAdmin() + return Database.use((db) => + db.delete(ModelProviderPreferenceTable).where(and(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace()), eq(ModelProviderPreferenceTable.model, model))), + ) + }, + ) + + export const listProviderPreferences = fn(z.void(), () => { + return Database.use(async (db) => { + const results = await db + .select({ model: ModelProviderPreferenceTable.model, preference: ModelProviderPreferenceTable.provider_preference }) + .from(ModelProviderPreferenceTable) + .where(eq(ModelProviderPreferenceTable.workspaceID, Actor.workspace())) + + const parsed: Record = {} + for (const row of results) { + try { + parsed[row.model] = ProviderPreferenceSchema.parse(JSON.parse(row.preference)) + } catch {} + } + return parsed + }) + }) } diff --git a/opencode/packages/console/core/src/schema/model-provider-preference.sql.ts b/opencode/packages/console/core/src/schema/model-provider-preference.sql.ts new file mode 100644 index 0000000..7c59275 --- /dev/null +++ b/opencode/packages/console/core/src/schema/model-provider-preference.sql.ts @@ -0,0 +1,14 @@ +import { mysqlTable, varchar, text, integer, uniqueIndex } from "drizzle-orm/mysql-core" +import { timestamps, workspaceColumns } from "../drizzle/types" +import { workspaceIndexes } from "./workspace.sql" + +export const ModelProviderPreferenceTable = mysqlTable( + "model_provider_preference", + { + ...workspaceColumns, + ...timestamps, + model: varchar("model", { length: 128 }).notNull(), + provider_preference: text("provider_preference").notNull(), + }, + (table) => [...workspaceIndexes(table), uniqueIndex("model_provider_preference_workspace_model").on(table.workspaceID, table.model)], +) diff --git a/opencode/packages/opencode/src/config/config.ts b/opencode/packages/opencode/src/config/config.ts index 055b4d6..d520e6a 100644 --- a/opencode/packages/opencode/src/config/config.ts +++ b/opencode/packages/opencode/src/config/config.ts @@ -1030,6 +1030,15 @@ export namespace Config { ) .optional() .describe("Variant-specific configuration"), + providerPreference: z + .object({ + order: z.array(z.string()).optional().describe("Provider routing order (e.g., ['anthropic', 'openai'])"), + only: z.array(z.string()).optional().describe("Whitelist of providers to use"), + ignore: z.array(z.string()).optional().describe("Blacklist of providers to skip"), + allow_fallbacks: z.boolean().optional().describe("Allow fallback to other providers (default: true)"), + }) + .optional() + .describe("OpenRouter provider preference for this model"), }), ) .optional(), diff --git a/opencode/packages/opencode/src/provider/provider.ts b/opencode/packages/opencode/src/provider/provider.ts index bc6e940..64bc19f 100644 --- a/opencode/packages/opencode/src/provider/provider.ts +++ b/opencode/packages/opencode/src/provider/provider.ts @@ -940,7 +940,7 @@ export namespace Provider { write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, }, - options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}, model.providerPreference ? { providerPreference: model.providerPreference } : {}), limit: { context: model.limit?.context ?? existingModel?.limit?.context ?? 0, output: model.limit?.output ?? existingModel?.limit?.output ?? 0, @@ -1177,6 +1177,27 @@ export namespace Provider { } } + // Inject OpenRouter provider preference for OpenRouter requests + if (model.providerID === "openrouter" && opts.body && opts.method === "POST") { + const providerPref = model.options?.providerPreference as { + order?: string[] + only?: string[] + ignore?: string[] + allow_fallbacks?: boolean + } | undefined + + if (providerPref && (providerPref.order || providerPref.only || providerPref.ignore)) { + const body = JSON.parse(opts.body as string) + body.provider = { + ...(providerPref.order && { order: providerPref.order }), + ...(providerPref.only && { only: providerPref.only }), + ...(providerPref.ignore && { ignore: providerPref.ignore }), + allow_fallbacks: providerPref.allow_fallbacks ?? true, + } + opts.body = JSON.stringify(body) + } + } + return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682