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.
This commit is contained in:
southseact-3d
2026-02-19 14:18:58 +00:00
parent 5627f7d758
commit 25367847ed
7 changed files with 311 additions and 8 deletions

View File

@@ -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;
}
}

View File

@@ -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() {
<tr>
<th>{i18n.t("workspace.models.table.model")}</th>
<th></th>
<th>Provider</th>
<th>{i18n.t("workspace.models.table.enabled")}</th>
</tr>
</thead>
<tbody>
<For each={modelsWithLab()}>
{({ 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 (
<tr data-slot="model-row" data-disabled={!isEnabled()}>
<td data-slot="model-name">
@@ -147,6 +199,43 @@ export function ModelSection() {
</div>
</td>
<td data-slot="model-lab">{lab}</td>
<td data-slot="model-provider">
<Show when={hasOpenRouter}>
<form action={updateProviderPreference} method="post" class={styles.providerForm}>
<input type="hidden" name="model" value={id} />
<input type="hidden" name="workspaceID" value={params.id} />
<select
name="provider"
disabled={!userInfo()?.isAdmin}
onChange={(e) => {
const form = e.currentTarget.closest("form")
if (form) form.requestSubmit()
}}
>
<For each={OPENROUTER_PROVIDERS}>
{(option) => (
<option value={option.value} selected={currentProvider() === option.value}>
{option.label}
</option>
)}
</For>
</select>
<label class={styles.fallbackLabel}>
<input
type="checkbox"
name="allowFallbacks"
checked={allowFallbacks()}
disabled={!userInfo()?.isAdmin || !currentProvider()}
onChange={(e) => {
const form = e.currentTarget.closest("form")
if (form) form.requestSubmit()
}}
/>
<span>Allow fallbacks</span>
</label>
</form>
</Show>
</td>
<td data-slot="model-toggle">
<form action={updateModel} method="post">
<input type="hidden" name="model" value={id} />

View File

@@ -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<ProviderPreference | undefined> {
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,

View File

@@ -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<typeof ProviderPreferenceSchema>
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<string, ProviderPreference> = {}
for (const row of results) {
try {
parsed[row.model] = ProviderPreferenceSchema.parse(JSON.parse(row.preference))
} catch {}
}
return parsed
})
})
}

View File

@@ -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)],
)

View File

@@ -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(),

View File

@@ -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