From 25367847ed78ca473a59b36a96afec441b66c659 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Thu, 19 Feb 2026 14:18:58 +0000 Subject: [PATCH] feat: add OpenRouter provider preference support Implement provider preference feature for OpenRouter models: 1. Database Schema: - Add ModelProviderPreferenceTable to store provider preferences per model 2. Console Core API: - Add ProviderPreferenceSchema for validation - Add setProviderPreference(), getProviderPreference(), deleteProviderPreference(), and listProviderPreferences() functions 3. Request Handler: - Modify handler.ts to fetch provider preferences from database - Pass provider preferences to OpenRouter in request body 4. Admin UI: - Add provider preference dropdown for OpenRouter models in model-section.tsx - Add 'Allow fallbacks' checkbox for fallback control - Add corresponding CSS styles 5. CLI Config: - Add providerPreference field to Provider model config schema - Support configuring provider preferences via opencode.json 6. Provider SDK: - Inject provider preferences into OpenRouter request body in fetch wrapper This allows workspace admins to specify preferred providers for OpenRouter models, ensuring models like Claude route through specific providers (e.g., Anthropic direct) rather than third-party providers. --- .../workspace/[id]/model-section.module.css | 55 ++++++++++ .../routes/workspace/[id]/model-section.tsx | 101 ++++++++++++++++-- .../app/src/routes/zen/util/handler.ts | 35 +++++- opencode/packages/console/core/src/model.ts | 82 ++++++++++++++ .../schema/model-provider-preference.sql.ts | 14 +++ .../packages/opencode/src/config/config.ts | 9 ++ .../opencode/src/provider/provider.ts | 23 +++- 7 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 opencode/packages/console/core/src/schema/model-provider-preference.sql.ts 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