Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
61bca0d
docs: add design spec for custom model settings override
everyoneexe Aug 4, 2026
3f7641f
feat: support custom model info overrides
everyoneexe Aug 4, 2026
e382bfe
fix: address custom model metadata review feedback
everyoneexe Aug 4, 2026
ee085a2
chore: remove internal design spec from PR
everyoneexe Aug 7, 2026
546ddf4
fix: harden tests and improve code clarity across custom model info
everyoneexe Aug 7, 2026
b1ea608
merge: integrate main into feat/custom-model-info-override
everyoneexe Aug 7, 2026
13ce7f0
fix: address maintainer review feedback on custom model info
everyoneexe Aug 8, 2026
eac42d6
test: add Playwright CT baseline screenshots for CustomModelInfoSettings
everyoneexe Aug 8, 2026
9ee1405
fix: allow minor pixel diff in Playwright CT visual comparisons
everyoneexe Aug 8, 2026
fabea64
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 8, 2026
63e6ce4
merge: resolve conflict with main (clearAllMocks helper migration)
everyoneexe Aug 9, 2026
879e403
Merge branch 'feat/custom-model-info-override' of github.com:everyone…
everyoneexe Aug 9, 2026
667fd5e
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 9, 2026
505c50a
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 11, 2026
854e816
fix(webview): fix useSelectedModel.spec.ts after merge conflict resol…
everyoneexe Aug 11, 2026
3b33fe0
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 16, 2026
ac8416f
fix: address custom model info review feedback
everyoneexe Aug 19, 2026
bbe3146
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 19, 2026
e9d404f
test: mock custom model info controls
everyoneexe Aug 19, 2026
8683068
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Aug 21, 2026
42290a8
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Aug 21, 2026
5080762
Merge branch 'main' into feat/custom-model-info-override
everyoneexe Aug 21, 2026
ecafb3b
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Aug 26, 2026
b2c22f7
Merge remote-tracking branch 'fork/feat/custom-model-info-override' i…
everyoneexe Aug 26, 2026
99107d2
test(webview): regenerate CustomModelInfoSettings visual baselines
everyoneexe Aug 26, 2026
801b40f
test(webview): use CI-rendered baselines for CustomModelInfoSettings
everyoneexe Aug 26, 2026
701cb7a
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Sep 23, 2026
fbc43ab
test(webview): migrate CustomModelInfoSettings visual tests to galler…
everyoneexe Sep 23, 2026
1def8cf
fix(settings): reset custom model info inputs when the persisted over…
everyoneexe Sep 23, 2026
f6229bf
refactor(settings): align customModelInfo with the openAiCustomModelI…
everyoneexe Sep 23, 2026
eaa0e7c
i18n(settings): localize the custom model metadata panel
everyoneexe Sep 24, 2026
581fdf5
fix(api): validate custom model numbers and cover the gateway clamp
everyoneexe Sep 24, 2026
d675af7
docs(api): record why the two surviving clamp mutants cannot be killed
everyoneexe Sep 24, 2026
5c9ecca
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Sep 25, 2026
89a7905
test(ci): match the CodeRabbit non-org access assertion to config
everyoneexe Sep 25, 2026
e50fb44
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe Sep 25, 2026
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
135 changes: 135 additions & 0 deletions packages/types/src/__tests__/custom-model-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { applyCustomModelInfo, customModelInfoSchema, toCustomModelInfo, type ModelInfo } from "../model.js"
import { providerIdentifiers, providerSettingsSchemaDiscriminated } from "../index.js"

describe("custom model info", () => {
const catalogModel: ModelInfo = {
maxTokens: 4096,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.2,
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.4,
description: "Provider metadata",
}

it("replaces discovered metadata with the configured snapshot", () => {
const resolved = applyCustomModelInfo(catalogModel, {
customModelInfo: {
contextWindow: 128_000,
maxTokens: 16_384,
supportsImages: true,
supportsPromptCache: true,
},
})

expect(resolved).toMatchObject({
contextWindow: 128_000,
maxTokens: 16_384,
supportsImages: true,
supportsPromptCache: true,
})
// The snapshot wins wholesale: catalog metadata it does not carry is dropped
// rather than merged, so the resolved value matches what the user configured.
expect(resolved?.description).toBeUndefined()
})

it("keeps provider-owned pricing from the catalog entry", () => {
const resolved = applyCustomModelInfo(catalogModel, {
customModelInfo: { contextWindow: 128_000, supportsPromptCache: false },
})

expect(resolved).toMatchObject({
inputPrice: 0.1,
outputPrice: 0.2,
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.4,
})
})

it("resolves an unlisted model without any catalog entry", () => {
const resolved = applyCustomModelInfo(undefined, {
customModelInfo: { contextWindow: 64_000, maxTokens: 8_192, supportsPromptCache: false },
})

expect(resolved).toMatchObject({ contextWindow: 64_000, maxTokens: 8_192 })
})

it("does not invent pricing when the catalog has none", () => {
const resolved = applyCustomModelInfo(undefined, {
customModelInfo: { contextWindow: 64_000, supportsPromptCache: false },
})

expect(resolved && "inputPrice" in resolved).toBe(false)
expect(resolved && "tiers" in resolved).toBe(false)
})

it("passes discovered metadata through untouched when nothing is configured", () => {
expect(applyCustomModelInfo(catalogModel, {})).toEqual(catalogModel)
expect(applyCustomModelInfo(catalogModel, { customModelInfo: null })).toEqual(catalogModel)
expect(applyCustomModelInfo(undefined, {})).toBeUndefined()
})

it("strips pricing when seeding the editor from a catalog entry", () => {
const seeded = toCustomModelInfo(catalogModel)

expect(seeded).toMatchObject({ contextWindow: 8192, maxTokens: 4096, description: "Provider metadata" })
expect("inputPrice" in seeded).toBe(false)
expect("cacheReadsPrice" in seeded).toBe(false)
})

it("rejects pricing fields in the persisted override schema", () => {
expect(
customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false, inputPrice: 1 })
.success,
).toBe(false)
expect(
providerSettingsSchemaDiscriminated.safeParse({
apiProvider: providerIdentifiers.openrouter,
customModelInfo: { contextWindow: 64_000, supportsPromptCache: false, outputPrice: 1 },
}).success,
).toBe(false)
})

it("requires a context window in the persisted override schema", () => {
expect(customModelInfoSchema.safeParse({ supportsPromptCache: false }).success).toBe(false)
expect(customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false }).success).toBe(
true,
)
})

// `modelInfoSchema` accepts any number because a provider catalog is not ours
// to police. User input is, and these two values drive token accounting and the
// outgoing max_completion_tokens.
it.each([
{ label: "zero", contextWindow: 0 },
{ label: "negative", contextWindow: -1 },
{ label: "fractional", contextWindow: 1.5 },
{ label: "non-finite", contextWindow: Number.POSITIVE_INFINITY },
{ label: "beyond safe integer range", contextWindow: Number.MAX_SAFE_INTEGER + 2 },
])("rejects a $label context window override", ({ contextWindow }) => {
expect(customModelInfoSchema.safeParse({ contextWindow, supportsPromptCache: false }).success).toBe(false)
})

it.each([
{ label: "zero", maxTokens: 0 },
{ label: "negative", maxTokens: -1 },
{ label: "fractional", maxTokens: 1.5 },
{ label: "non-finite", maxTokens: Number.POSITIVE_INFINITY },
])("rejects a $label max output tokens override", ({ maxTokens }) => {
expect(
customModelInfoSchema.safeParse({ contextWindow: 64_000, maxTokens, supportsPromptCache: false }).success,
).toBe(false)
})

it("accepts an absent max output tokens as provider-decided", () => {
expect(customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false }).success).toBe(
true,
)
expect(
customModelInfoSchema.safeParse({ contextWindow: 64_000, maxTokens: null, supportsPromptCache: false })
.success,
).toBe(true)
})
})
89 changes: 89 additions & 0 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,95 @@ export const modelInfoSchema = z.object({

export type ModelInfo = z.infer<typeof modelInfoSchema>

/**
* User-supplied model metadata for a model whose discovered metadata is
* incomplete or unavailable.
*
* This mirrors the long-standing `openAiCustomModelInfo` contract: the stored
* value is a complete snapshot that the settings UI prefills from the
* discovered catalog entry. Resolution therefore stays a single expression at
* every call site and a configured override can never resolve to `undefined`.
*
* Pricing is deliberately excluded. Router catalogs own prices and refresh
* them, so a user-held price snapshot would go stale and silently corrupt cost
* reporting.
*/
export const customModelInfoSchema = modelInfoSchema
.omit({
inputPrice: true,
outputPrice: true,
cacheWritesPrice: true,
cacheReadsPrice: true,
longContextPricing: true,
tiers: true,
})
.extend({
// `modelInfoSchema` accepts any number because a provider catalog is not
// ours to validate. User input is, and these two values drive token
// accounting and the outgoing max_completion_tokens, so reject the values
// that would produce NaN percentages or a request the gateway rejects.
contextWindow: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
maxTokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullish(),
})
// `strict()` is what actually rejects a pricing field: omitting a key only
// drops it from the shape, it does not make the value invalid.
.strict()

export type CustomModelInfo = z.infer<typeof customModelInfoSchema>

export type CustomModelInfoSettings = {
customModelInfo?: CustomModelInfo | null
}

/**
* Strips provider-owned pricing so the settings UI can prefill the editor from
* a discovered catalog entry.
*/
export const toCustomModelInfo = (info: ModelInfo): CustomModelInfo => {
const {
inputPrice: _inputPrice,
outputPrice: _outputPrice,
cacheWritesPrice: _cacheWritesPrice,
cacheReadsPrice: _cacheReadsPrice,
longContextPricing: _longContextPricing,
tiers: _tiers,
...rest
} = info

return rest
}

/**
* Resolves the effective model metadata for providers that expose the custom
* model info editor.
*
* A configured override replaces the discovered metadata wholesale; pricing is
* always read back from the catalog entry. Without an override the discovered
* metadata passes through untouched.
*/
export const applyCustomModelInfo = (
info: ModelInfo | undefined,
settings: CustomModelInfoSettings | undefined,
): ModelInfo | undefined => {
const override = settings?.customModelInfo

if (!override) {
return info
}

// Copy only the price keys the catalog actually carries, so the result never
// gains explicit `undefined` pricing fields.
return {
...override,
...(info?.inputPrice !== undefined && { inputPrice: info.inputPrice }),
...(info?.outputPrice !== undefined && { outputPrice: info.outputPrice }),
...(info?.cacheWritesPrice !== undefined && { cacheWritesPrice: info.cacheWritesPrice }),
...(info?.cacheReadsPrice !== undefined && { cacheReadsPrice: info.cacheReadsPrice }),
...(info?.longContextPricing !== undefined && { longContextPricing: info.longContextPricing }),
...(info?.tiers !== undefined && { tiers: info.tiers }),
}
}

export type ModelRecord = Record<string, ModelInfo>

export type RouterModels = Record<DynamicProvider | LocalProvider, ModelRecord>
Expand Down
22 changes: 22 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ export type DynamicProvider = (typeof dynamicProviders)[number]
export const isDynamicProvider = (key: string): key is DynamicProvider =>
dynamicProviders.includes(key as DynamicProvider)

/**
* Providers that accept user-supplied `customModelInfo` overrides in the
* settings UI. This is a strict subset of `dynamicProviders` — only providers
* whose model metadata can be manually adjusted by the user.
*
* NOTE: Of these, only vercel-ai-gateway and zoo-gateway apply the overlay
* inside `RouterProvider.resolveModelInfo()`. The others (openrouter, requesty,
* unbound) apply it in their own overridden `getModel()` methods.
*/
export const customModelInfoProviders = [
providerIdentifiers.openrouter,
providerIdentifiers.requesty,
providerIdentifiers.unbound,
providerIdentifiers.vercelAiGateway,
providerIdentifiers.zooGateway,
] as const

export type CustomModelInfoProvider = (typeof customModelInfoProviders)[number]

export const isCustomModelInfoProvider = (key: string): key is CustomModelInfoProvider =>
customModelInfoProviders.includes(key as CustomModelInfoProvider)

/**
* LocalProvider
*
Expand Down
3 changes: 2 additions & 1 deletion packages/types/src/provider-settings/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod"

import { reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js"
import { customModelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js"
import type { ProviderIdentifier } from "../provider-identifiers.js"

export const API_PROVIDER_FIELD = "apiProvider"
Expand All @@ -18,6 +18,7 @@ export const baseProviderSettingsShape = {
modelMaxTokens: z.number().optional(),
modelMaxThinkingTokens: z.number().optional(),
verbosity: verbosityLevelsSchema.optional(),
customModelInfo: customModelInfoSchema.nullish(),
}

export const apiModelIdProviderModelShape = {
Expand Down
11 changes: 11 additions & 0 deletions src/api/providers/__tests__/kenari.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ describe("KenariHandler", () => {
expect(result.info.supportsPromptCache).toBe(false)
})

it("does not apply gateway-only custom metadata overrides", async () => {
const handler = new KenariHandler({
...mockOptions,
customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false },
})
const result = await handler.fetchModel()

expect(result.info.contextWindow).toBe(1_048_576)
expect(result.info.maxTokens).toBe(32_768)
})

it("falls back to the default model id when none is configured", async () => {
const handler = new KenariHandler({ kenariApiKey: "test-key" })
const result = await handler.fetchModel()
Expand Down
Loading
Loading