From 9102efacf2465ffef2a38c8da2ef5f53e29448f4 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 18 May 2026 14:07:20 -0700 Subject: [PATCH 1/6] feat: add model_override adapter with parameterized adapter config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a conditional model-override adapter that rewrites the provider model name based on request payload fields (e.g. switching to a -fast variant when reasoning is disabled). Key changes: - Adapter config format upgraded from bare strings to uniform { name, options } entries. Legacy string format is normalized lazily on read via z.preprocess() and normalizeAdapterEntries() in config-repository — no DB migration needed. - ProviderAdapter interface gains optional 'options' parameter on preDispatch/postDispatch/stream hooks. Existing adapters (reasoning_content, suppress_developer_role) updated with _options param (backward compatible). - resolveAdapters() now returns ResolvedAdapter[] (adapter + options pair) instead of bare ProviderAdapter[]. - Dispatcher updated to destructure { adapter, options } and pass options through to all adapter call sites. - model_override adapter: evaluates OR-semantics conditions against the transformed provider payload using dotted-path field resolution. Rewrites payload.model when any condition matches. - Frontend: KNOWN_ADAPTERS updated, model-level adapter checkbox logic adapted for { name, options } format, inline rule editor for model_override conditions added to ProviderModelsEditor. - All 1344 backend tests passing. --- .../adapters/adapter-resolver.test.ts | 101 ++++-- packages/backend/src/config.ts | 56 +++- packages/backend/src/db/config-repository.ts | 52 ++- .../backend/src/services/adapter-resolver.ts | 39 +-- packages/backend/src/services/dispatcher.ts | 32 +- .../__tests__/model-override.adapter.test.ts | 273 +++++++++++++++ .../src/transformers/adapters/index.ts | 4 +- .../adapters/model-override.adapter.ts | 114 +++++++ .../adapters/reasoning-content.adapter.ts | 6 +- .../suppress-developer-role.adapter.ts | 6 +- .../backend/src/types/provider-adapter.ts | 20 +- .../reasoning-content-adapter.test.ts | 2 +- .../providers/ProviderAdvancedEditor.tsx | 21 +- .../providers/ProviderModelsEditor.tsx | 317 +++++++++++++++++- 14 files changed, 941 insertions(+), 102 deletions(-) create mode 100644 packages/backend/src/transformers/adapters/__tests__/model-override.adapter.test.ts create mode 100644 packages/backend/src/transformers/adapters/model-override.adapter.ts diff --git a/packages/backend/src/__tests__/adapters/adapter-resolver.test.ts b/packages/backend/src/__tests__/adapters/adapter-resolver.test.ts index 65e5a38d..cebfceb9 100644 --- a/packages/backend/src/__tests__/adapters/adapter-resolver.test.ts +++ b/packages/backend/src/__tests__/adapters/adapter-resolver.test.ts @@ -3,10 +3,7 @@ import { resolveAdapters } from '../../services/adapter-resolver'; import type { RouteResult } from '../../services/router'; // Minimal RouteResult factory -function makeRoute( - providerAdapter?: string | string[], - modelAdapter?: string | string[] -): RouteResult { +function makeRoute(providerAdapter?: any[], modelAdapter?: any[]): RouteResult { return { provider: 'test-provider', model: 'test-model', @@ -29,48 +26,86 @@ describe('resolveAdapters', () => { expect(resolveAdapters(route)).toHaveLength(0); }); - it('resolves a provider-level string adapter', () => { - const route = makeRoute('reasoning_content'); - const adapters = resolveAdapters(route); - expect(adapters).toHaveLength(1); - expect(adapters[0]!.name).toBe('reasoning_content'); + it('resolves a provider-level adapter entry', () => { + const route = makeRoute([{ name: 'reasoning_content', options: {} }]); + const resolved = resolveAdapters(route); + expect(resolved).toHaveLength(1); + expect(resolved[0]!.adapter.name).toBe('reasoning_content'); + expect(resolved[0]!.options).toEqual({}); }); - it('resolves a provider-level array adapter', () => { - const route = makeRoute(['reasoning_content', 'suppress_developer_role']); - const adapters = resolveAdapters(route); - expect(adapters.map((a) => a.name)).toEqual(['reasoning_content', 'suppress_developer_role']); + it('resolves a model-level adapter entry', () => { + const route = makeRoute(undefined, [{ name: 'suppress_developer_role', options: {} }]); + const resolved = resolveAdapters(route); + expect(resolved).toHaveLength(1); + expect(resolved[0]!.adapter.name).toBe('suppress_developer_role'); + expect(resolved[0]!.options).toEqual({}); }); - it('resolves a model-level string adapter', () => { - const route = makeRoute(undefined, 'suppress_developer_role'); - const adapters = resolveAdapters(route); - expect(adapters).toHaveLength(1); - expect(adapters[0]!.name).toBe('suppress_developer_role'); + it('merges provider-level then model-level adapters in order', () => { + const route = makeRoute( + [{ name: 'reasoning_content', options: {} }], + [{ name: 'suppress_developer_role', options: {} }] + ); + const resolved = resolveAdapters(route); + expect(resolved.map((r) => r.adapter.name)).toEqual([ + 'reasoning_content', + 'suppress_developer_role', + ]); }); - it('merges provider-level then model-level adapters in order', () => { - const route = makeRoute('reasoning_content', 'suppress_developer_role'); - const adapters = resolveAdapters(route); - expect(adapters.map((a) => a.name)).toEqual(['reasoning_content', 'suppress_developer_role']); + it('passes options through from config', () => { + const rules = [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ]; + const route = makeRoute([{ name: 'model_override', options: { rules } }]); + const resolved = resolveAdapters(route); + expect(resolved).toHaveLength(1); + expect(resolved[0]!.adapter.name).toBe('model_override'); + expect(resolved[0]!.options).toEqual({ rules }); }); it('skips and warns on unknown adapter names (does not throw)', () => { - const route = makeRoute('nonexistent_adapter'); - // Should not throw; unknown names are skipped - const adapters = resolveAdapters(route); - expect(adapters).toHaveLength(0); + const route = makeRoute([{ name: 'nonexistent_adapter', options: {} }]); + const resolved = resolveAdapters(route); + expect(resolved).toHaveLength(0); }); it('handles mixed valid and invalid adapter names', () => { - const route = makeRoute(['reasoning_content', 'bogus'], 'suppress_developer_role'); - const adapters = resolveAdapters(route); - expect(adapters.map((a) => a.name)).toEqual(['reasoning_content', 'suppress_developer_role']); + const route = makeRoute( + [ + { name: 'reasoning_content', options: {} }, + { name: 'bogus', options: {} }, + ], + [{ name: 'suppress_developer_role', options: {} }] + ); + const resolved = resolveAdapters(route); + expect(resolved.map((r) => r.adapter.name)).toEqual([ + 'reasoning_content', + 'suppress_developer_role', + ]); + }); + + it('handles multiple provider-level adapter entries', () => { + const route = makeRoute([ + { name: 'reasoning_content', options: {} }, + { name: 'suppress_developer_role', options: {} }, + ]); + const resolved = resolveAdapters(route); + expect(resolved.map((r) => r.adapter.name)).toEqual([ + 'reasoning_content', + 'suppress_developer_role', + ]); }); - it('handles model-level array adapters', () => { - const route = makeRoute(undefined, ['reasoning_content', 'suppress_developer_role']); - const adapters = resolveAdapters(route); - expect(adapters.map((a) => a.name)).toEqual(['reasoning_content', 'suppress_developer_role']); + it('resolves model_override adapter', () => { + const route = makeRoute([{ name: 'model_override', options: { rules: [] } }]); + const resolved = resolveAdapters(route); + expect(resolved).toHaveLength(1); + expect(resolved[0]!.adapter.name).toBe('model_override'); }); }); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 2284a3db..0f10284d 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -61,7 +61,61 @@ const PricingSchema = z.discriminatedUnion('source', [ }), ]); -const AdapterConfigSchema = z.union([z.string(), z.array(z.string())]).optional(); +// ─── Adapter Config ───────────────────────────────────────────────── +// Adapters are configured as an array of { name, options } entries. +// Legacy bare-string forms are normalized at read time in config-repository. + +const ModelOverrideConditionSchema = z.object({ + /** JSON dotted path into the payload (e.g. "reasoning.enabled", "reasoning.effort"). */ + field: z.string().min(1), + /** If omitted, matches when the field is present (any value). If set, matches when value equals this. */ + value: z.any().optional(), +}); + +const ModelOverrideRuleSchema = z.object({ + /** The model name in the payload to match against (e.g. "deepseek-r1"). */ + model: z.string().min(1), + /** The model name to rewrite to when conditions match (e.g. "deepseek-r1-fast"). */ + rewriteTo: z.string().min(1), + /** Conditions — ANY match triggers the rewrite (OR semantics). */ + conditions: z.array(ModelOverrideConditionSchema).min(1), +}); + +const ModelOverrideOptionsSchema = z.object({ + rules: z.array(ModelOverrideRuleSchema).min(1), +}); + +const AdapterEntrySchema = z.object({ + name: z.string().min(1), + options: z.record(z.string(), z.any()).default({}), +}); + +/** + * Accepts both the legacy format (string | string[]) and the new + * uniform format ({ name, options }[]) and normalizes everything + * to AdapterEntry[]. This ensures backward compatibility with + * existing YAML configs while enforcing the canonical shape at + * validation time. + */ +const AdapterConfigSchema = z.preprocess((val) => { + if (val === undefined || val === null) return undefined; + // Already an array (or single entry) — normalize each element + const arr = Array.isArray(val) ? val : [val]; + return arr.map((entry: any) => { + if (typeof entry === 'string') { + return { name: entry, options: {} }; + } + if (entry && typeof entry === 'object' && 'name' in entry) { + return { name: entry.name, options: entry.options ?? {} }; + } + return entry; // Let Zod produce a clear validation error + }); +}, z.array(AdapterEntrySchema).optional()); + +export type ModelOverrideCondition = z.infer; +export type ModelOverrideRule = z.infer; +export type ModelOverrideOptions = z.infer; +export type AdapterEntry = z.infer; const ModelProviderConfigSchema = z.object({ pricing: PricingSchema.default({ diff --git a/packages/backend/src/db/config-repository.ts b/packages/backend/src/db/config-repository.ts index 315715cf..238a59bc 100644 --- a/packages/backend/src/db/config-repository.ts +++ b/packages/backend/src/db/config-repository.ts @@ -50,6 +50,43 @@ function toJson(value: unknown): string | unknown { return value; // PG jsonb handles objects natively } +/** + * Normalize adapter entries from DB storage to the canonical { name, options } form. + * + * Legacy rows stored adapter entries as bare strings (e.g. ["reasoning_content"]). + * This function converts them to the uniform object form: + * [{ name: "reasoning_content", options: {} }] + * + * Rows are self-healing: on next save through the API, the normalized form + * is persisted back to the DB. + */ +function normalizeAdapterEntries( + raw: unknown +): Array<{ name: string; options: Record }> | null { + if (raw === null || raw === undefined) return null; + const arr = Array.isArray(raw) ? raw : [raw]; + if (arr.length === 0) return null; + + return arr + .map((entry) => { + if (typeof entry === 'string') { + // Legacy bare-string form + return { name: entry, options: {} }; + } + if (entry && typeof entry === 'object' && 'name' in entry) { + // Already in object form + return { + name: (entry as any).name, + options: (entry as any).options ?? {}, + }; + } + // Malformed entry — skip with a warning (don't crash) + logger.warn(`Skipping malformed adapter entry: ${JSON.stringify(entry)}`); + return null; + }) + .filter((e): e is { name: string; options: Record } => e !== null); +} + /** * Encrypt a JSON value for storage in a TEXT column. * JSON-serializes the value, then encrypts the resulting string. @@ -311,8 +348,8 @@ export class ConfigRepository { gpuFlopsTflop: config.gpu_flops_tflop ?? null, gpuPowerDrawWatts: config.gpu_power_draw_watts ?? null, adapter: - config.adapter && (Array.isArray(config.adapter) ? config.adapter.length > 0 : true) - ? toJson(Array.isArray(config.adapter) ? config.adapter : [config.adapter]) + config.adapter && Array.isArray(config.adapter) && config.adapter.length > 0 + ? toJson(config.adapter) : null, timeoutMs: config.timeoutMs ?? null, maxConcurrency: config.maxConcurrency ?? null, @@ -375,8 +412,8 @@ export class ConfigRepository { accessVia: cfg.access_via ? toJson(cfg.access_via) : null, extraBody: cfg.extraBody ? toJson(cfg.extraBody) : null, adapter: - cfg.adapter && (Array.isArray(cfg.adapter) ? cfg.adapter.length > 0 : true) - ? toJson(Array.isArray(cfg.adapter) ? cfg.adapter : [cfg.adapter]) + cfg.adapter && Array.isArray(cfg.adapter) && cfg.adapter.length > 0 + ? toJson(cfg.adapter) : null, maxConcurrency: cfg.maxConcurrency ?? null, sortOrder: idx, @@ -450,7 +487,7 @@ export class ConfigRepository { ...(m.modelType ? { type: m.modelType } : {}), ...(m.accessVia ? { access_via: parseJson(m.accessVia) } : {}), ...(m.extraBody ? { extraBody: parseJson(m.extraBody) } : {}), - ...(m.adapter ? { adapter: parseJson(m.adapter) } : {}), + ...(m.adapter ? { adapter: normalizeAdapterEntries(parseJson(m.adapter)) } : {}), ...(m.maxConcurrency != null ? { maxConcurrency: m.maxConcurrency } : {}), }; } @@ -495,8 +532,9 @@ export class ConfigRepository { })(), ...(quota_checker ? { quota_checker } : {}), ...(() => { - const adapterVal = parseJson(row.adapter); - return Array.isArray(adapterVal) && adapterVal.length > 0 ? { adapter: adapterVal } : {}; + const adapterVal = parseJson(row.adapter); + const normalized = normalizeAdapterEntries(adapterVal); + return normalized && normalized.length > 0 ? { adapter: normalized } : {}; })(), // GPU Profile settings — resolve named profiles to concrete values for // backward compatibility with existing DB rows that may only have gpuProfile diff --git a/packages/backend/src/services/adapter-resolver.ts b/packages/backend/src/services/adapter-resolver.ts index 6bf63f99..ff567a97 100644 --- a/packages/backend/src/services/adapter-resolver.ts +++ b/packages/backend/src/services/adapter-resolver.ts @@ -1,5 +1,6 @@ -import type { ProviderAdapter } from '../types/provider-adapter'; +import type { ProviderAdapter, ResolvedAdapter } from '../types/provider-adapter'; import type { RouteResult } from './router'; +import type { AdapterEntry } from '../config'; import { ADAPTER_REGISTRY } from '../transformers/adapters'; import { logger } from '../utils/logger'; @@ -10,40 +11,32 @@ import { logger } from '../utils/logger'; * 1. Provider-level `adapter` (applies to all models under the provider) * 2. Model-level `adapter` (appended after provider-level adapters) * - * Both fields accept either a single string or an array of strings. - * Unknown adapter names are logged as warnings and skipped (rather than - * throwing) so that a misconfigured adapter doesn't take down the whole route. + * Each entry is an { name, options } object. Unknown adapter names are logged + * as warnings and skipped (rather than throwing) so that a misconfigured + * adapter doesn't take down the whole route. * * Returns an empty array when no adapters are configured — zero-cost path. */ -export function resolveAdapters(route: RouteResult): ProviderAdapter[] { - const names: string[] = [ - ...normalizeAdapterField(route.config.adapter), - ...normalizeAdapterField(route.modelConfig?.adapter), +export function resolveAdapters(route: RouteResult): ResolvedAdapter[] { + const entries: AdapterEntry[] = [ + ...(route.config.adapter ?? []), + ...(route.modelConfig?.adapter ?? []), ]; - if (names.length === 0) return []; + if (entries.length === 0) return []; - const adapters: ProviderAdapter[] = []; - for (const name of names) { - const adapter = ADAPTER_REGISTRY[name]; + const resolved: ResolvedAdapter[] = []; + for (const entry of entries) { + const adapter = ADAPTER_REGISTRY[entry.name]; if (!adapter) { logger.warn( - `Unknown adapter '${name}' configured for provider '${route.provider}' ` + + `Unknown adapter '${entry.name}' configured for provider '${route.provider}' ` + `model '${route.model}' — skipping` ); continue; } - adapters.push(adapter); + resolved.push({ adapter, options: entry.options }); } - return adapters; -} - -/** - * Coerce the adapter config field (string | string[] | undefined) to string[]. - */ -function normalizeAdapterField(field: string | string[] | undefined): string[] { - if (!field) return []; - return Array.isArray(field) ? field : [field]; + return resolved; } diff --git a/packages/backend/src/services/dispatcher.ts b/packages/backend/src/services/dispatcher.ts index 2ddc66c6..408279c3 100644 --- a/packages/backend/src/services/dispatcher.ts +++ b/packages/backend/src/services/dispatcher.ts @@ -24,7 +24,7 @@ import { CooldownParserRegistry } from './cooldown-parsers'; import { getConfig, getProviderTypes } from '../config'; import { applyModelBehaviors } from './model-behaviors'; import { resolveAdapters } from './adapter-resolver'; -import type { ProviderAdapter } from '../types/provider-adapter'; +import type { ResolvedAdapter } from '../types/provider-adapter'; import { getModels } from '@earendil-works/pi-ai'; import type { StallConfig } from './inspectors/stall-inspector'; import { VisionDescriptorService } from './vision-descriptor-service'; @@ -2325,7 +2325,7 @@ export class Dispatcher { route: RouteResult, transformer: any, targetApiType: string, - adapters: ProviderAdapter[] = [] + adapters: ResolvedAdapter[] = [] ): Promise<{ payload: any; bypassTransformation: boolean }> { let providerPayload: any; let bypassTransformation = false; @@ -2399,13 +2399,13 @@ export class Dispatcher { } // Apply provider/model adapters (preDispatch) in configured order - for (const adapter of adapters) { - providerPayload = adapter.preDispatch(providerPayload); + for (const { adapter, options } of adapters) { + providerPayload = adapter.preDispatch(providerPayload, options); } if (adapters.length > 0) { logger.debug( - `Adapters applied (preDispatch): [${adapters.map((a) => a.name).join(', ')}] ` + + `Adapters applied (preDispatch): [${adapters.map((a) => a.adapter.name).join(', ')}] ` + `for ${route.provider}/${route.model}` ); } @@ -2634,7 +2634,7 @@ export class Dispatcher { route: RouteResult, targetApiType: string, bypassTransformation: boolean, - adapters: ProviderAdapter[] = [] + adapters: ResolvedAdapter[] = [] ): UnifiedChatResponse { logger.debug('Streaming response detected'); @@ -2642,11 +2642,11 @@ export class Dispatcher { // If any adapter defines preDispatchStreamChunk, pipe the raw SSE stream // through a rewrite transform before it reaches transformStream(). - const streamAdapters = adapters.filter((a) => a.preDispatchStreamChunk); + const streamAdapters = adapters.filter((a) => a.adapter.preDispatchStreamChunk); if (streamAdapters.length > 0) { rawStream = rawStream.pipeThrough(this.buildSseRewriteTransform(streamAdapters)); logger.debug( - `Stream adapters applied (preDispatchStreamChunk): [${streamAdapters.map((a) => a.name).join(', ')}] ` + + `Stream adapters applied (preDispatchStreamChunk): [${streamAdapters.map((a) => a.adapter.name).join(', ')}] ` + `for ${route.provider}/${route.model}` ); } @@ -2670,7 +2670,7 @@ export class Dispatcher { * Handles chunked delivery — lines may arrive split across multiple chunks. */ private buildSseRewriteTransform( - adapters: ProviderAdapter[] + adapters: ResolvedAdapter[] ): TransformStream { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -2684,8 +2684,8 @@ export class Dispatcher { buffer = lines.pop() ?? ''; for (const line of lines) { let rewritten = line; - for (const adapter of adapters) { - rewritten = adapter.preDispatchStreamChunk!(rewritten); + for (const { adapter, options } of adapters) { + rewritten = adapter.preDispatchStreamChunk!(rewritten, options); } controller.enqueue(encoder.encode(rewritten + '\n')); } @@ -2693,8 +2693,8 @@ export class Dispatcher { flush(controller) { if (buffer.length > 0) { let rewritten = buffer; - for (const adapter of adapters) { - rewritten = adapter.preDispatchStreamChunk!(rewritten); + for (const { adapter, options } of adapters) { + rewritten = adapter.preDispatchStreamChunk!(rewritten, options); } controller.enqueue(encoder.encode(rewritten)); } @@ -2712,7 +2712,7 @@ export class Dispatcher { targetApiType: string, transformer: any, bypassTransformation: boolean, - adapters: ProviderAdapter[] = [] + adapters: ResolvedAdapter[] = [] ): Promise { let responseBody = await this.parseJsonResponseBody( response, @@ -2725,12 +2725,12 @@ export class Dispatcher { // Apply provider/model adapters (postDispatch) in reverse order if (adapters.length > 0) { for (let i = adapters.length - 1; i >= 0; i--) { - responseBody = adapters[i]!.postDispatch(responseBody); + responseBody = adapters[i]!.adapter.postDispatch(responseBody, adapters[i]!.options); } logger.debug( `Adapters applied (postDispatch): [${[...adapters] .reverse() - .map((a) => a.name) + .map((a) => a.adapter.name) .join(', ')}] ` + `for ${route.provider}/${route.model}` ); } diff --git a/packages/backend/src/transformers/adapters/__tests__/model-override.adapter.test.ts b/packages/backend/src/transformers/adapters/__tests__/model-override.adapter.test.ts new file mode 100644 index 00000000..d5f89a44 --- /dev/null +++ b/packages/backend/src/transformers/adapters/__tests__/model-override.adapter.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest'; +import { modelOverrideAdapter, resolveDottedPath } from '../model-override.adapter'; + +// ── resolveDottedPath ──────────────────────────────────────────────────── + +describe('resolveDottedPath', () => { + it('resolves a top-level field', () => { + expect(resolveDottedPath({ model: 'x' }, 'model')).toBe('x'); + }); + + it('resolves a nested field', () => { + expect(resolveDottedPath({ reasoning: { enabled: false } }, 'reasoning.enabled')).toBe(false); + }); + + it('resolves deeply nested field', () => { + expect(resolveDottedPath({ a: { b: { c: 42 } } }, 'a.b.c')).toBe(42); + }); + + it('returns undefined for missing path', () => { + expect(resolveDottedPath({ foo: 1 }, 'bar')).toBeUndefined(); + }); + + it('returns undefined for partially missing path', () => { + expect(resolveDottedPath({ a: {} }, 'a.b.c')).toBeUndefined(); + }); + + it('returns undefined when traversing through null', () => { + expect(resolveDottedPath({ a: null }, 'a.b')).toBeUndefined(); + }); + + it('returns undefined when traversing through primitive', () => { + expect(resolveDottedPath({ a: 5 }, 'a.b')).toBeUndefined(); + }); +}); + +// ── preDispatch ────────────────────────────────────────────────────────── + +describe('model_override adapter', () => { + describe('preDispatch', () => { + it('returns payload unchanged when no options provided', () => { + const payload = { model: 'deepseek-r1', messages: [] }; + expect(modelOverrideAdapter.preDispatch(payload)).toBe(payload); + }); + + it('returns payload unchanged when options has no rules', () => { + const payload = { model: 'deepseek-r1', messages: [] }; + expect(modelOverrideAdapter.preDispatch(payload, {})).toBe(payload); + }); + + it('returns payload unchanged when rules is empty array', () => { + const payload = { model: 'deepseek-r1', messages: [] }; + expect(modelOverrideAdapter.preDispatch(payload, { rules: [] })).toBe(payload); + }); + + it('returns payload unchanged when no rule matches the model', () => { + const payload = { model: 'other-model', messages: [] }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('other-model'); + }); + + it('rewrites model when condition with value matches', () => { + const payload = { model: 'deepseek-r1', reasoning: { enabled: false }, messages: [] }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1-fast'); + }); + + it('rewrites model when condition with presence check matches (field exists)', () => { + const payload = { model: 'deepseek-r1', reasoning: {}, messages: [] }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning' }], // no value = presence check + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1-fast'); + }); + + it('does not rewrite when presence check fails (field absent)', () => { + const payload = { model: 'deepseek-r1', messages: [] }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning' }], // no value = presence check + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1'); + }); + + it('does not rewrite when value check fails', () => { + const payload = { model: 'deepseek-r1', reasoning: { enabled: true }, messages: [] }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1'); + }); + + it('rewrites when ANY condition matches (OR semantics)', () => { + const payload = { + model: 'deepseek-r1', + reasoning: { enabled: true, effort: 'none' }, + messages: [], + }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [ + { field: 'reasoning.enabled', value: false }, // doesn't match + { field: 'reasoning.effort', value: 'none' }, // matches + ], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1-fast'); + }); + + it('does not rewrite when NO condition matches (OR semantics, all fail)', () => { + const payload = { + model: 'deepseek-r1', + reasoning: { enabled: true, effort: 'high' }, + messages: [], + }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [ + { field: 'reasoning.enabled', value: false }, // doesn't match + { field: 'reasoning.effort', value: 'none' }, // doesn't match + ], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1'); + }); + + it('supports multiple rules — only the first matching rule applies', () => { + const payload = { + model: 'deepseek-r1', + reasoning: { enabled: false }, + messages: [], + }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-mini', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1-fast'); + }); + + it('supports reverse rule (fast → full when reasoning enabled)', () => { + const payload = { + model: 'deepseek-r1-fast', + reasoning: { enabled: true }, + messages: [], + }; + const options = { + rules: [ + { + model: 'deepseek-r1-fast', + rewriteTo: 'deepseek-r1', + conditions: [{ field: 'reasoning.enabled', value: true }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1'); + }); + + it('preserves all other payload fields', () => { + const payload = { + model: 'deepseek-r1', + reasoning: { enabled: false }, + messages: [{ role: 'user', content: 'hello' }], + temperature: 0.7, + stream: true, + }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1-fast'); + expect(result.messages).toEqual([{ role: 'user', content: 'hello' }]); + expect(result.temperature).toBe(0.7); + expect(result.stream).toBe(true); + }); + + it('matches value using strict equality', () => { + // String "false" should not match boolean false + const payload = { + model: 'deepseek-r1', + reasoning: { enabled: 'false' }, + messages: [], + }; + const options = { + rules: [ + { + model: 'deepseek-r1', + rewriteTo: 'deepseek-r1-fast', + conditions: [{ field: 'reasoning.enabled', value: false }], + }, + ], + }; + const result = modelOverrideAdapter.preDispatch(payload, options); + expect(result.model).toBe('deepseek-r1'); // no rewrite — strict equality + }); + }); + + describe('postDispatch', () => { + it('returns response unchanged', () => { + const response = { id: 'resp-1', model: 'deepseek-r1-fast' }; + expect(modelOverrideAdapter.postDispatch(response)).toBe(response); + }); + + it('returns response unchanged even with options', () => { + const response = { id: 'resp-1', model: 'deepseek-r1-fast' }; + expect(modelOverrideAdapter.postDispatch(response, { rules: [] })).toBe(response); + }); + }); +}); diff --git a/packages/backend/src/transformers/adapters/index.ts b/packages/backend/src/transformers/adapters/index.ts index 9d749b0f..4aff788c 100644 --- a/packages/backend/src/transformers/adapters/index.ts +++ b/packages/backend/src/transformers/adapters/index.ts @@ -1,12 +1,14 @@ import type { ProviderAdapter } from '../../types/provider-adapter'; import { reasoningContentAdapter } from './reasoning-content.adapter'; import { suppressDeveloperRoleAdapter } from './suppress-developer-role.adapter'; +import { modelOverrideAdapter } from './model-override.adapter'; /** * Registry of all built-in provider adapters. - * Keys must match the strings used in provider/model config `adapter` fields. + * Keys must match the name field used in provider/model config `adapter` entries. */ export const ADAPTER_REGISTRY: Record = { [reasoningContentAdapter.name]: reasoningContentAdapter, [suppressDeveloperRoleAdapter.name]: suppressDeveloperRoleAdapter, + [modelOverrideAdapter.name]: modelOverrideAdapter, }; diff --git a/packages/backend/src/transformers/adapters/model-override.adapter.ts b/packages/backend/src/transformers/adapters/model-override.adapter.ts new file mode 100644 index 00000000..a39afaf1 --- /dev/null +++ b/packages/backend/src/transformers/adapters/model-override.adapter.ts @@ -0,0 +1,114 @@ +import type { ProviderAdapter } from '../../types/provider-adapter'; +import type { ModelOverrideOptions } from '../../config'; +import { logger } from '../../utils/logger'; + +/** + * model_override adapter + * + * Conditionally rewrites `payload.model` based on the presence or values of + * arbitrary fields in the outgoing request payload. + * + * This enables providers that expose reasoning variants as separate model + * names (e.g. `deepseek-r1` with reasoning, `deepseek-r1-fast` without) to + * be routed correctly even though the upstream client sets a `reasoning` + * field rather than picking the variant model. + * + * Options schema (ModelOverrideOptions): + * rules: [ + * { + * model: "deepseek-r1", // match payload.model + * rewriteTo: "deepseek-r1-fast", // rewrite to this model + * conditions: [ // ANY match triggers rewrite (OR) + * { field: "reasoning.enabled", value: false }, + * { field: "reasoning.effort", value: "none" }, + * ] + * }, + * { + * model: "deepseek-r1-fast", + * rewriteTo: "deepseek-r1", + * conditions: [ + * { field: "reasoning.enabled", value: true }, + * ] + * }, + * ] + * + * Outbound (preDispatch): + * - If payload.model matches a rule's `model` field AND any condition is satisfied, + * payload.model is rewritten to `rewriteTo`. + * + * Inbound (postDispatch): no-op — model name in responses is not rewritten. + * + * Stream: no-op — providers do not typically echo model names in SSE chunks. + */ +export const modelOverrideAdapter: ProviderAdapter = { + name: 'model_override', + + preDispatch(payload: Record, options?: Record): Record { + if (!options || !options.rules || !Array.isArray(options.rules)) return payload; + + const rules = options.rules as ModelOverrideOptions['rules']; + + for (const rule of rules) { + if (payload.model !== rule.model) continue; + + // Check conditions — any match (OR) triggers the rewrite + const matched = rule.conditions.some((condition) => evaluateCondition(payload, condition)); + + if (matched) { + logger.debug( + `model_override: rewriting model '${payload.model}' → '${rule.rewriteTo}' ` + + `(rule matched on ${rule.model})` + ); + return { ...payload, model: rule.rewriteTo }; + } + } + + return payload; + }, + + postDispatch(response: Record, _options?: Record): Record { + return response; + }, +}; + +/** + * Evaluate a single condition against a payload. + * + * - If `condition.value` is provided: matches when the field at the dotted path + * equals that value (strict equality). + * - If `condition.value` is omitted: matches when the field at the dotted path + * is present (not undefined). + */ +function evaluateCondition( + payload: Record, + condition: { field: string; value?: any } +): boolean { + const actual = resolveDottedPath(payload, condition.field); + + if (condition.value !== undefined) { + // Value match — strict equality + return actual === condition.value; + } + + // Presence check — field must exist (not undefined) + return actual !== undefined; +} + +/** + * Resolve a dotted path (e.g. "reasoning.enabled") into the corresponding + * value in a nested object. Returns `undefined` if the path cannot be fully + * resolved. + */ +export function resolveDottedPath(obj: Record, path: string): any { + const segments = path.split('.'); + let current: any = obj; + + for (const segment of segments) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = current[segment]; + } + + return current; +} diff --git a/packages/backend/src/transformers/adapters/reasoning-content.adapter.ts b/packages/backend/src/transformers/adapters/reasoning-content.adapter.ts index 74698f83..bdec8f45 100644 --- a/packages/backend/src/transformers/adapters/reasoning-content.adapter.ts +++ b/packages/backend/src/transformers/adapters/reasoning-content.adapter.ts @@ -24,7 +24,7 @@ import type { ProviderAdapter } from '../../types/provider-adapter'; export const reasoningContentAdapter: ProviderAdapter = { name: 'reasoning_content', - preDispatch(payload: Record): Record { + preDispatch(payload: Record, _options?: Record): Record { if (!Array.isArray(payload.messages)) return payload; const messages = payload.messages.map((msg: any) => { @@ -47,14 +47,14 @@ export const reasoningContentAdapter: ProviderAdapter = { return { ...payload, messages }; }, - postDispatch(response: Record): Record { + postDispatch(response: Record, _options?: Record): Record { // The OpenAI transformer's transformResponse() already reads // `message.reasoning_content` natively, so no field rename is needed // on the inbound path. This is intentionally a no-op. return response; }, - preDispatchStreamChunk(line: string): string { + preDispatchStreamChunk(line: string, _options?: Record): string { // Replace `"reasoning":` with `"reasoning_content":` in the JSON payload // of SSE data lines so the provider receives the correct field name. if (!line.startsWith('data:')) return line; diff --git a/packages/backend/src/transformers/adapters/suppress-developer-role.adapter.ts b/packages/backend/src/transformers/adapters/suppress-developer-role.adapter.ts index 62fb28dc..2d3b8541 100644 --- a/packages/backend/src/transformers/adapters/suppress-developer-role.adapter.ts +++ b/packages/backend/src/transformers/adapters/suppress-developer-role.adapter.ts @@ -20,7 +20,7 @@ import type { ProviderAdapter } from '../../types/provider-adapter'; export const suppressDeveloperRoleAdapter: ProviderAdapter = { name: 'suppress_developer_role', - preDispatch(payload: Record): Record { + preDispatch(payload: Record, _options?: Record): Record { if (!Array.isArray(payload.messages)) return payload; const messages = payload.messages.map((msg: any) => { @@ -31,11 +31,11 @@ export const suppressDeveloperRoleAdapter: ProviderAdapter = { return { ...payload, messages }; }, - postDispatch(response: Record): Record { + postDispatch(response: Record, _options?: Record): Record { return response; }, - preDispatchStreamChunk(line: string): string { + preDispatchStreamChunk(line: string, _options?: Record): string { if (!line.startsWith('data:')) return line; return line.replace(/"role":"developer"/g, '"role":"system"'); }, diff --git a/packages/backend/src/types/provider-adapter.ts b/packages/backend/src/types/provider-adapter.ts index c79267a4..8f5bf9ff 100644 --- a/packages/backend/src/types/provider-adapter.ts +++ b/packages/backend/src/types/provider-adapter.ts @@ -1,5 +1,14 @@ import type { UnifiedChatRequest } from './unified'; +/** + * Resolved adapter paired with its per-config options. + * Produced by resolveAdapters() and consumed by the dispatcher. + */ +export interface ResolvedAdapter { + adapter: ProviderAdapter; + options?: Record; +} + /** * ProviderAdapter * @@ -11,6 +20,9 @@ import type { UnifiedChatRequest } from './unified'; * * Implementing preDispatch/postDispatch is mandatory. Stream chunk hooks are * optional — omitting them means stream chunks pass through unmodified. + * + * The `options` parameter receives the per-entry options from config, if any. + * Stateless adapters can ignore it. */ export interface ProviderAdapter { /** Unique registry key (matches config adapter name). */ @@ -21,26 +33,26 @@ export interface ProviderAdapter { * Called after transformRequest(), before the HTTP call. * Must return a (potentially new) provider payload object. */ - preDispatch(payload: Record): Record; + preDispatch(payload: Record, options?: Record): Record; /** * Rewrite the raw provider JSON response before it is passed to * transformResponse(). Called only for non-streaming responses. * Must return a (potentially new) response object. */ - postDispatch(response: Record): Record; + postDispatch(response: Record, options?: Record): Record; /** * Rewrite a raw SSE line (e.g. `data: {...}`) on its way out of the * provider, before transformStream() consumes it. * Return the line unchanged if no rewrite is needed. */ - preDispatchStreamChunk?(line: string): string; + preDispatchStreamChunk?(line: string, options?: Record): string; /** * Rewrite a raw SSE line after transformStream() / formatStream() have * produced it, just before it is written to the client. * Return the line unchanged if no rewrite is needed. */ - postDispatchStreamChunk?(line: string): string; + postDispatchStreamChunk?(line: string, options?: Record): string; } diff --git a/packages/backend/test/integration/reasoning-content-adapter.test.ts b/packages/backend/test/integration/reasoning-content-adapter.test.ts index dcb11ac7..040a845d 100644 --- a/packages/backend/test/integration/reasoning-content-adapter.test.ts +++ b/packages/backend/test/integration/reasoning-content-adapter.test.ts @@ -36,7 +36,7 @@ const BASE_CONFIG = { pricing: { source: 'simple', input: 0, output: 0 }, access_via: ['chat'], // adapter configured at model level — matches the real production setup - adapter: ['reasoning_content'], + adapter: [{ name: 'reasoning_content', options: {} }], }, }, }, diff --git a/packages/frontend/src/components/providers/ProviderAdvancedEditor.tsx b/packages/frontend/src/components/providers/ProviderAdvancedEditor.tsx index 8e1baec6..76fbb5b9 100644 --- a/packages/frontend/src/components/providers/ProviderAdvancedEditor.tsx +++ b/packages/frontend/src/components/providers/ProviderAdvancedEditor.tsx @@ -19,6 +19,12 @@ export const KNOWN_ADAPTERS: { value: string; label: string; description: string label: 'Suppress Developer Role', description: 'Rewrites the "developer" role to "system" for providers that do not support it.', }, + { + value: 'model_override', + label: 'Model Override', + description: + 'Conditionally rewrites the model name based on request fields (e.g. switching to a -fast variant when reasoning is disabled).', + }, ]; interface Props { @@ -122,8 +128,11 @@ export function ProviderAdvancedEditor({ per-model.
- {KNOWN_ADAPTERS.map((a) => { - const active = (editingProvider.adapter ?? []).includes(a.value); + {KNOWN_ADAPTERS.filter((a) => a.value !== 'model_override').map((a) => { + const adapterEntries: any[] = editingProvider.adapter ?? []; + const active = adapterEntries.some( + (e: any) => (typeof e === 'string' ? e : e.name) === a.value + ); return (
)} From b5c95b19b93ea19b9fd2622d49d706dc79f7146e Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 18 May 2026 14:13:54 -0700 Subject: [PATCH 2/6] fix: preserve unknown request fields in OpenAI chat-to-chat transformation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OpenAI transformer builds the outbound provider payload for a chat-to-chat transformation, it now starts from a spread of the original request body instead of an empty object. Explicitly-mapped fields are overlaid on top, so transformations (e.g. tool normalization, system message prepending) still apply correctly, but unknown fields like enable_thinking, thinking, chat_template_kwargs, thinking_budget, and budget_tokens are no longer silently dropped. Cross-API transformations (chat→messages, chat→gemini) remain fully explicit since they are fundamentally different protocols. This fixes the model_override adapter's inability to match on non-standard reasoning fields that were previously stripped during transformation. --- packages/backend/src/transformers/openai.ts | 28 ++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/transformers/openai.ts b/packages/backend/src/transformers/openai.ts index 9fd03d2d..067604e2 100644 --- a/packages/backend/src/transformers/openai.ts +++ b/packages/backend/src/transformers/openai.ts @@ -84,15 +84,25 @@ export class OpenAITransformer implements Transformer { }) : undefined; - const out: any = { - model: request.model, - messages, - max_tokens: request.max_tokens, - temperature: request.temperature, - stream: request.stream, - tools: normalizedTools && normalizedTools.length > 0 ? normalizedTools : undefined, - tool_choice: request.tool_choice, - }; + // When the incoming API type matches our outgoing type (chat -> chat), + // start from the original body to preserve unknown fields (e.g. + // enable_thinking, chat_template_kwargs, budget_tokens) that the + // explicit mapping below would otherwise drop. The explicitly-mapped + // fields are then overlaid on top to apply any necessary transformations. + // Cross-API transformations (chat -> messages, chat -> gemini) remain + // fully explicit since they're fundamentally different protocols. + const isSameApiType = request.originalBody && request.incomingApiType?.toLowerCase() === 'chat'; + + const out: any = isSameApiType ? { ...request.originalBody } : {}; + + // Override with explicitly-transformed fields + out.model = request.model; + out.messages = messages; + out.max_tokens = request.max_tokens; + out.temperature = request.temperature; + out.stream = request.stream; + out.tools = normalizedTools && normalizedTools.length > 0 ? normalizedTools : undefined; + out.tool_choice = request.tool_choice; if (request.response_format) { if (request.response_format.type === 'json_schema' && request.response_format.json_schema) { From 75812a00bdb081153ecb3e7de6404167fbd7f5d0 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 18 May 2026 14:27:33 -0700 Subject: [PATCH 3/6] fix(ui,docs): improve model_override rule editor and update docs/openapi UI: - Make match model input 2x wider (flex: 2) so long model names fit - Add column headers above conditions: 'Field path (dotted)' and 'Value (blank = presence check)' with a delete-button spacer - Shorten condition input placeholders to avoid crowding under headers Docs: - Add model_override adapter to CONFIGURATION.md adapters table - Add dedicated 'Model Override Adapter' section with rule/condition field descriptions, JSON config example, and usage notes OpenAPI: - Update ProviderConfig.yaml adapter schema to accept { name, options } object entries in addition to bare strings - Add model_override to the available adapters list with description - Update model-level adapter field to match the new schema --- docs/CONFIGURATION.md | 61 +++++++++++++++++++ .../components/schemas/ProviderConfig.yaml | 44 +++++++++++-- .../providers/ProviderModelsEditor.tsx | 57 ++++++++++++++--- 3 files changed, 149 insertions(+), 13 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 992cb222..2e58d47f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -140,6 +140,7 @@ Adapters can be set at **provider level** (applied to every model under the prov |---------|-------------| | `reasoning_content` | Renames `reasoning` / `thinking.content` → `reasoning_content` on outbound assistant messages for providers that use Fireworks/DeepSeek field naming (e.g. Fireworks DeepSeek-R1). Fixes *"Extra inputs are not permitted, field: messages[N].reasoning"* errors. | | `suppress_developer_role` | Rewrites the `developer` role to `system` on outbound messages for providers that do not support the newer OpenAI `developer` role. | +| `model_override` | Conditionally rewrites the provider model name based on request payload fields. Used for providers that expose reasoning variants as separate model names rather than respecting reasoning-related fields in the request body. See [Model Override Adapter](#model-override-adapter) below. | **Example — provider-level:** ```json @@ -164,6 +165,66 @@ PUT /v0/management/providers/fireworks Adapters are applied in order on outbound (preDispatch) and in reverse on inbound (postDispatch). Pass-through optimisation is automatically disabled when any adapter is active. +### Model Override Adapter + +The `model_override` adapter conditionally rewrites the provider model name based on the values or presence of fields in the request payload. This is useful for providers that expose reasoning variants as **separate model names** (e.g. `model-name` with reasoning, `model-name-fast` without) rather than respecting reasoning-related fields in the request body. + +**How it works:** + +When the resolved provider model matches a rule's `model` field AND **any** of the rule's conditions are satisfied (OR semantics), the model name is rewritten to `rewriteTo`. Conditions use dotted paths into the request payload. + +**Configuration:** + +The `model_override` adapter is configured at **model level** only (not provider level). It accepts a `rules` array in its options: + +```json +{ + "models": { + "zai-org/GLM-5.1-FP8": { + "adapter": [ + { + "name": "model_override", + "options": { + "rules": [ + { + "model": "zai-org/GLM-5.1-FP8", + "rewriteTo": "glm-5.1-fast", + "conditions": [ + { "field": "enable_thinking", "value": false }, + { "field": "reasoning.enabled", "value": false }, + { "field": "reasoning.effort", "value": "none" }, + { "field": "budget_tokens", "value": 0 } + ] + } + ] + } + } + ] + } + } +} +``` + +**Rule fields:** + +| Field | Description | +|-------|-------------| +| `model` | The provider model name to match against (must match the resolved target model) | +| `rewriteTo` | The model name to send to the provider instead | +| `conditions` | Array of conditions; **any** match triggers the rewrite | + +**Condition fields:** + +| Field | Description | +|-------|-------------| +| `field` | Dotted path into the request payload (e.g. `reasoning.enabled`, `chat_template_kwargs.enable_thinking`) | +| `value` | Value to match (strict equality). If omitted, the condition matches when the field is present (any value) | + +**Notes:** +- The adapter operates on the **transformed provider payload**, so fields must survive API transformation to be matchable. For chat-to-chat requests, all fields from the original request body are preserved (including non-standard fields like `enable_thinking`, `thinking_budget`, etc.). +- Multiple rules are evaluated in order; only the first matching rule applies. +- The rewrite is transparent to the client — billing and usage tracking still reference the original canonical model name. + ### Provider Quota Checkers Quota checkers monitor upstream provider rate limits and prevent routing to exhausted providers. diff --git a/docs/openapi/components/schemas/ProviderConfig.yaml b/docs/openapi/components/schemas/ProviderConfig.yaml index 14759e52..c39bb08a 100644 --- a/docs/openapi/components/schemas/ProviderConfig.yaml +++ b/docs/openapi/components/schemas/ProviderConfig.yaml @@ -109,11 +109,25 @@ properties: - type: string - type: array items: - type: string + oneOf: + - type: string + - type: object + required: + - name + properties: + name: + type: string + description: Adapter name. + options: + type: object + additionalProperties: true + description: Adapter-specific options. description: > Adapter name(s) applied to this specific model. Appended after - any provider-level adapters. See the provider-level `adapter` - field for available values. + any provider-level adapters. Accepts bare strings or + `{ name, options }` objects for parameterized adapters + (e.g. `model_override` with rules). See the provider-level + `adapter` field for available values. description: > Map of model ID → per-model configuration. Allows per-model pricing overrides and access aliases. @@ -176,13 +190,28 @@ properties: - type: string - type: array items: - type: string + oneOf: + - type: string + - type: object + required: + - name + properties: + name: + type: string + description: Adapter name. + options: + type: object + additionalProperties: true + description: Adapter-specific options. description: > Adapter name(s) applied to every model under this provider. Adapters rewrite outbound request payloads (preDispatch) and inbound provider responses (postDispatch) to fix provider-specific field-name incompatibilities. Applied before model-level adapters. + Adapters can be specified as bare strings (backward compatible) or as + objects with `{ name, options }` for adapters that require configuration. + Available adapters: - `reasoning_content` — Renames `reasoning`/`thinking.content` → @@ -192,6 +221,13 @@ properties: - `suppress_developer_role` — Rewrites the `developer` role to `system` for providers that do not support the OpenAI `developer` role. + - `model_override` — Conditionally rewrites the provider model name + based on request payload fields. Accepts a `rules` array in options + where each rule specifies a `model` to match, a `rewriteTo` model name, + and an array of `conditions` (OR semantics) using dotted field paths. + Used for providers that expose reasoning variants as separate model + names rather than respecting reasoning-related request fields. + Pass-through optimisation is automatically disabled when any adapter is active. timeoutMs: diff --git a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx index 04c5e8d6..d8bd6d0e 100644 --- a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx +++ b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx @@ -889,11 +889,19 @@ export function ProviderModelsEditor({ borderRadius: 'var(--radius-sm)', padding: '6px', marginBottom: '4px', - background: 'var(--color-bg-deep)', + background: 'var(--color-bg-subtle)', }} > + {/* Rewrite rule */} +
+ Rewrite +
- +
+ {/* Conditions separator */} +
+
+ Conditions (any match triggers rewrite) +
+ {/* Condition column headers */} +
+ + Field path (dotted) + + + Value (blank = presence check) + + {/* spacer for delete button column */} + +
{/* Conditions */} {(rule.conditions ?? []).map((cond: any, cIdx: number) => (
{ const updated = [...rules]; @@ -1010,7 +1049,7 @@ export function ProviderModelsEditor({ style={{ flex: 1 }} /> Date: Mon, 18 May 2026 14:28:39 -0700 Subject: [PATCH 4/6] fix(ui): make match model in override rule read-only and auto-populated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The match model field in a model_override rule is always the model name of the target being configured — it makes no sense to edit it. Changed to a read-only shaded div showing the model name (mId) with ellipsis overflow for long names. New rules auto-populate the model field with mId. --- .../providers/ProviderModelsEditor.tsx | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx index d8bd6d0e..17c7d0f7 100644 --- a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx +++ b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx @@ -903,28 +903,22 @@ export function ProviderModelsEditor({ alignItems: 'center', }} > - { - const updated = [...rules]; - updated[rIdx] = { - ...updated[rIdx], - model: e.target.value, - }; - const newAdapters = modelAdapters.map((entry: any) => - typeof entry !== 'string' && - entry.name === 'model_override' - ? { - ...entry, - options: { ...entry.options, rules: updated }, - } - : entry - ); - updateModelConfig(mId, { adapter: newAdapters }); +
+ title={mId} + > + {mId} +
@@ -1155,7 +1149,7 @@ export function ProviderModelsEditor({ size="sm" onClick={() => { const newRule = { - model: '', + model: mId, rewriteTo: '', conditions: [{ field: '' }], }; From 6c1293ccbd9d0cbedf7825ad3a1c67be3dde0a57 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 18 May 2026 14:30:03 -0700 Subject: [PATCH 5/6] fix(ui): make field path input 2x wider in override conditions --- .../frontend/src/components/providers/ProviderModelsEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx index 17c7d0f7..27defa7e 100644 --- a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx +++ b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx @@ -1040,7 +1040,7 @@ export function ProviderModelsEditor({ ); updateModelConfig(mId, { adapter: newAdapters }); }} - style={{ flex: 1 }} + style={{ flex: 2 }} /> Date: Mon, 18 May 2026 14:39:32 -0700 Subject: [PATCH 6/6] fix(ui): wrap adapter inputs in flex-sized divs so widths actually apply The Input component has w-full (width: 100%) on its inner , which overrode any flex value set via the style prop. Moving flex to a wrapper div makes the field-path column genuinely 2x wider than the value column. --- .../providers/ProviderModelsEditor.tsx | 189 +++++++++--------- 1 file changed, 100 insertions(+), 89 deletions(-) diff --git a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx index 27defa7e..214fc7f6 100644 --- a/packages/frontend/src/components/providers/ProviderModelsEditor.tsx +++ b/packages/frontend/src/components/providers/ProviderModelsEditor.tsx @@ -922,28 +922,29 @@ export function ProviderModelsEditor({ - { - const updated = [...rules]; - updated[rIdx] = { - ...updated[rIdx], - rewriteTo: e.target.value, - }; - const newAdapters = modelAdapters.map((entry: any) => - typeof entry !== 'string' && - entry.name === 'model_override' - ? { - ...entry, - options: { ...entry.options, rules: updated }, - } - : entry - ); - updateModelConfig(mId, { adapter: newAdapters }); - }} - style={{ flex: 1 }} - /> +
+ { + const updated = [...rules]; + updated[rIdx] = { + ...updated[rIdx], + rewriteTo: e.target.value, + }; + const newAdapters = modelAdapters.map((entry: any) => + typeof entry !== 'string' && + entry.name === 'model_override' + ? { + ...entry, + options: { ...entry.options, rules: updated }, + } + : entry + ); + updateModelConfig(mId, { adapter: newAdapters }); + }} + /> +