feat(server): apply official llm alias routing policy
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type { OfficialProviderAliasRoute } from '../../../../../schemas/official-catalog'
|
||||||
import type { UsageInfo } from '../../../../../services/domain/billing/billing'
|
import type { UsageInfo } from '../../../../../services/domain/billing/billing'
|
||||||
import type { GatewayCallback } from '../../gateway'
|
import type { GatewayCallback } from '../../gateway'
|
||||||
import type { V1RouteDeps } from '../../types'
|
import type { V1RouteDeps } from '../../types'
|
||||||
@@ -5,6 +6,7 @@ import type { V1RouteDeps } from '../../types'
|
|||||||
import { useLogger } from '@guiiai/logg'
|
import { useLogger } from '@guiiai/logg'
|
||||||
|
|
||||||
import { extractUsageFromBody } from '../../../../../services/domain/billing/billing'
|
import { extractUsageFromBody } from '../../../../../services/domain/billing/billing'
|
||||||
|
import { createBadRequestError } from '../../../../../utils/error'
|
||||||
import { nanoid } from '../../../../../utils/id'
|
import { nanoid } from '../../../../../utils/id'
|
||||||
import { buildSafeResponseHeaders } from '../../http/response'
|
import { buildSafeResponseHeaders } from '../../http/response'
|
||||||
import { createOpenAiRouteBilling } from '../../middlewares/billing'
|
import { createOpenAiRouteBilling } from '../../middlewares/billing'
|
||||||
@@ -41,7 +43,8 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
|||||||
|
|
||||||
const body = input.body
|
const body = input.body
|
||||||
const requestedAlias = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto'
|
const requestedAlias = typeof body.model === 'string' && body.model.length > 0 ? body.model : 'auto'
|
||||||
const requestModel = await resolveChatModelAlias(deps, requestedAlias)
|
const aliasPlan = await resolveChatModelAliasPlan(deps, requestedAlias)
|
||||||
|
let requestModel = aliasPlan.modelIds[0]
|
||||||
|
|
||||||
const stream = !!body.stream
|
const stream = !!body.stream
|
||||||
logger.withFields({
|
logger.withFields({
|
||||||
@@ -81,11 +84,19 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
|||||||
// fluxConsumed: 0 while real cost was incurred — a silent revenue leak.
|
// fluxConsumed: 0 while real cost was incurred — a silent revenue leak.
|
||||||
// Source: codex review 2026-05-15 HIGH #1.
|
// Source: codex review 2026-05-15 HIGH #1.
|
||||||
const clientAbort = input.abortSignal
|
const clientAbort = input.abortSignal
|
||||||
const routeCtx = newRouteContext()
|
let routeCtx = newRouteContext()
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await telemetry.runWithSpan(span, () =>
|
const routed = await telemetry.runWithSpan(span, () =>
|
||||||
deps.llmRouter.route({ modelName: requestModel, body, headers: {}, abortSignal: clientAbort }, routeCtx))
|
routeChatAliasCandidates({
|
||||||
|
deps,
|
||||||
|
body,
|
||||||
|
modelIds: aliasPlan.modelIds,
|
||||||
|
abortSignal: clientAbort,
|
||||||
|
}))
|
||||||
|
response = routed.response
|
||||||
|
routeCtx = routed.routeCtx
|
||||||
|
requestModel = routed.modelId
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
telemetry.failSpan(span, 'Router exhausted or unknown model')
|
telemetry.failSpan(span, 'Router exhausted or unknown model')
|
||||||
@@ -198,7 +209,11 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChatModelAlias(deps: V1RouteDeps, aliasId: string): Promise<string> {
|
interface ChatModelAliasPlan {
|
||||||
|
modelIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise<ChatModelAliasPlan> {
|
||||||
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
const config = await deps.configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
||||||
const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
|
const defaultModel = await deps.configKV.getOrThrow('DEFAULT_CHAT_MODEL')
|
||||||
const modelIds = [
|
const modelIds = [
|
||||||
@@ -211,8 +226,82 @@ async function resolveChatModelAlias(deps: V1RouteDeps, aliasId: string): Promis
|
|||||||
})
|
})
|
||||||
|
|
||||||
const alias = await deps.officialCatalogService.resolveEnabledAlias('llm', aliasId)
|
const alias = await deps.officialCatalogService.resolveEnabledAlias('llm', aliasId)
|
||||||
const primary = alias.routes.find(route => route.pool === 'primary')
|
const primaryRoutes = alias.routes.filter(route => route.pool === 'primary')
|
||||||
return (primary ?? alias.routes[0]).routerModelId
|
const fallbackRoutes = alias.fallbackEnabled
|
||||||
|
? alias.routes.filter(route => route.pool === 'fallback')
|
||||||
|
: []
|
||||||
|
const orderedPrimaryRoutes = alias.loadBalancingEnabled
|
||||||
|
? weightedRouteOrder(primaryRoutes)
|
||||||
|
: primaryRoutes
|
||||||
|
const routedModelIds = uniqueModelIds([...orderedPrimaryRoutes, ...fallbackRoutes])
|
||||||
|
|
||||||
|
if (routedModelIds.length === 0) {
|
||||||
|
throw createBadRequestError('Official provider alias has no enabled route', 'OFFICIAL_ALIAS_ROUTE_NOT_FOUND', {
|
||||||
|
surface: 'llm',
|
||||||
|
aliasId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modelIds: routedModelIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeChatAliasCandidates(input: {
|
||||||
|
deps: V1RouteDeps
|
||||||
|
body: Record<string, unknown>
|
||||||
|
modelIds: string[]
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
}): Promise<{
|
||||||
|
modelId: string
|
||||||
|
response: Response
|
||||||
|
routeCtx: ReturnType<typeof newRouteContext>
|
||||||
|
}> {
|
||||||
|
let lastError: unknown
|
||||||
|
for (const modelId of input.modelIds) {
|
||||||
|
const routeCtx = newRouteContext()
|
||||||
|
try {
|
||||||
|
const response = await input.deps.llmRouter.route({
|
||||||
|
modelName: modelId,
|
||||||
|
body: input.body,
|
||||||
|
headers: {},
|
||||||
|
abortSignal: input.abortSignal,
|
||||||
|
}, routeCtx)
|
||||||
|
return { modelId, response, routeCtx }
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (input.abortSignal?.aborted)
|
||||||
|
throw err
|
||||||
|
lastError = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedRouteOrder(routes: OfficialProviderAliasRoute[]): OfficialProviderAliasRoute[] {
|
||||||
|
if (routes.length <= 1)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
const totalWeight = routes.reduce((sum, route) => sum + Math.max(route.weight, 0), 0)
|
||||||
|
if (totalWeight <= 0)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
let cursor = Math.random() * totalWeight
|
||||||
|
const selectedIndex = routes.findIndex((route) => {
|
||||||
|
cursor -= Math.max(route.weight, 0)
|
||||||
|
return cursor < 0
|
||||||
|
})
|
||||||
|
if (selectedIndex < 0)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
const selected = routes[selectedIndex]
|
||||||
|
return [
|
||||||
|
selected,
|
||||||
|
...routes.filter((_, index) => index !== selectedIndex),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueModelIds(routes: OfficialProviderAliasRoute[]): string[] {
|
||||||
|
return Array.from(new Set(routes.map(route => route.routerModelId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamChatCompletion(input: {
|
function streamChatCompletion(input: {
|
||||||
|
|||||||
@@ -694,6 +694,197 @@ describe('v1CompletionsRoutes', () => {
|
|||||||
expect(route).not.toHaveBeenCalled()
|
expect(route).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to the alias fallback pool when every primary route is exhausted', async () => {
|
||||||
|
const route = vi.fn(async ({ modelName }, ctx) => {
|
||||||
|
if (modelName === 'openai/primary')
|
||||||
|
throw new ApiError(502, 'BAD_GATEWAY', 'primary exhausted')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.provider = 'openrouter'
|
||||||
|
ctx.upstreamModel = modelName
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const officialCatalogService = createMockOfficialCatalogService({
|
||||||
|
resolveEnabledAlias: vi.fn(async () => ({
|
||||||
|
id: 'alias-auto',
|
||||||
|
surface: 'llm' as const,
|
||||||
|
aliasId: 'auto',
|
||||||
|
displayName: 'Auto',
|
||||||
|
enabled: true,
|
||||||
|
displayOrder: 0,
|
||||||
|
fallbackEnabled: true,
|
||||||
|
loadBalancingEnabled: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
routes: [
|
||||||
|
{ id: 'route-primary', aliasId: 'alias-auto', routerModelId: 'openai/primary', pool: 'primary' as const, enabled: true, weight: 1, displayOrder: 0, createdAt: now, updatedAt: now },
|
||||||
|
{ id: 'route-fallback', aliasId: 'alias-auto', routerModelId: 'openai/fallback', pool: 'fallback' as const, enabled: true, weight: 1, displayOrder: 1, createdAt: now, updatedAt: now },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const app = createTestApp(
|
||||||
|
createMockFluxService(),
|
||||||
|
createMockConfigKV({
|
||||||
|
DEFAULT_CHAT_MODEL: 'openai/primary',
|
||||||
|
LLM_ROUTER_CONFIG: {
|
||||||
|
llm: { models: { 'openai/primary': { upstreams: [] }, 'openai/fallback': { upstreams: [] } } },
|
||||||
|
tts: { models: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
createMockLlmRouter({ route }),
|
||||||
|
createMockLlmTracing(),
|
||||||
|
createMockProductEventService(),
|
||||||
|
createMockVoicePackService(),
|
||||||
|
officialCatalogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/v1/openai/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'auto', messages: [] }),
|
||||||
|
}),
|
||||||
|
{ user: testUser } as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(route).toHaveBeenCalledTimes(2)
|
||||||
|
expect(route).toHaveBeenNthCalledWith(1, expect.objectContaining({ modelName: 'openai/primary' }), expect.any(Object))
|
||||||
|
expect(route).toHaveBeenNthCalledWith(2, expect.objectContaining({ modelName: 'openai/fallback' }), expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not use the alias fallback pool when fallback is disabled', async () => {
|
||||||
|
const route = vi.fn(async () => {
|
||||||
|
throw new ApiError(502, 'BAD_GATEWAY', 'primary exhausted')
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const officialCatalogService = createMockOfficialCatalogService({
|
||||||
|
resolveEnabledAlias: vi.fn(async () => ({
|
||||||
|
id: 'alias-auto',
|
||||||
|
surface: 'llm' as const,
|
||||||
|
aliasId: 'auto',
|
||||||
|
displayName: 'Auto',
|
||||||
|
enabled: true,
|
||||||
|
displayOrder: 0,
|
||||||
|
fallbackEnabled: false,
|
||||||
|
loadBalancingEnabled: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
routes: [
|
||||||
|
{ id: 'route-primary', aliasId: 'alias-auto', routerModelId: 'openai/primary', pool: 'primary' as const, enabled: true, weight: 1, displayOrder: 0, createdAt: now, updatedAt: now },
|
||||||
|
{ id: 'route-fallback', aliasId: 'alias-auto', routerModelId: 'openai/fallback', pool: 'fallback' as const, enabled: true, weight: 1, displayOrder: 1, createdAt: now, updatedAt: now },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const app = createTestApp(
|
||||||
|
createMockFluxService(),
|
||||||
|
createMockConfigKV({
|
||||||
|
DEFAULT_CHAT_MODEL: 'openai/primary',
|
||||||
|
LLM_ROUTER_CONFIG: {
|
||||||
|
llm: { models: { 'openai/primary': { upstreams: [] }, 'openai/fallback': { upstreams: [] } } },
|
||||||
|
tts: { models: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
createMockLlmRouter({ route }),
|
||||||
|
createMockLlmTracing(),
|
||||||
|
createMockProductEventService(),
|
||||||
|
createMockVoicePackService(),
|
||||||
|
officialCatalogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/v1/openai/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'auto', messages: [] }),
|
||||||
|
}),
|
||||||
|
{ user: testUser } as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.status).toBe(502)
|
||||||
|
expect(route).toHaveBeenCalledTimes(1)
|
||||||
|
expect(route).toHaveBeenCalledWith(expect.objectContaining({ modelName: 'openai/primary' }), expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses weighted primary routing when alias load balancing is enabled', async () => {
|
||||||
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.95)
|
||||||
|
const route = vi.fn(async ({ modelName }, ctx) => {
|
||||||
|
if (ctx) {
|
||||||
|
ctx.provider = 'openrouter'
|
||||||
|
ctx.upstreamModel = modelName
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const officialCatalogService = createMockOfficialCatalogService({
|
||||||
|
resolveEnabledAlias: vi.fn(async () => ({
|
||||||
|
id: 'alias-auto',
|
||||||
|
surface: 'llm' as const,
|
||||||
|
aliasId: 'auto',
|
||||||
|
displayName: 'Auto',
|
||||||
|
enabled: true,
|
||||||
|
displayOrder: 0,
|
||||||
|
fallbackEnabled: false,
|
||||||
|
loadBalancingEnabled: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
routes: [
|
||||||
|
{ id: 'route-a', aliasId: 'alias-auto', routerModelId: 'openai/light', pool: 'primary' as const, enabled: true, weight: 1, displayOrder: 0, createdAt: now, updatedAt: now },
|
||||||
|
{ id: 'route-b', aliasId: 'alias-auto', routerModelId: 'openai/heavy', pool: 'primary' as const, enabled: true, weight: 9, displayOrder: 1, createdAt: now, updatedAt: now },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const app = createTestApp(
|
||||||
|
createMockFluxService(),
|
||||||
|
createMockConfigKV({
|
||||||
|
DEFAULT_CHAT_MODEL: 'openai/light',
|
||||||
|
LLM_ROUTER_CONFIG: {
|
||||||
|
llm: { models: { 'openai/light': { upstreams: [] }, 'openai/heavy': { upstreams: [] } } },
|
||||||
|
tts: { models: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
createMockLlmRouter({ route }),
|
||||||
|
createMockLlmTracing(),
|
||||||
|
createMockProductEventService(),
|
||||||
|
createMockVoicePackService(),
|
||||||
|
officialCatalogService,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.fetch(
|
||||||
|
new Request('http://localhost/api/v1/openai/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'auto', messages: [] }),
|
||||||
|
}),
|
||||||
|
{ user: testUser } as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(route).toHaveBeenCalledTimes(1)
|
||||||
|
expect(route).toHaveBeenCalledWith(expect.objectContaining({ modelName: 'openai/heavy' }), expect.any(Object))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
randomSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('records Langfuse chat generation with the router-resolved upstream model', async () => {
|
it('records Langfuse chat generation with the router-resolved upstream model', async () => {
|
||||||
const llmRouter = createMockLlmRouter({
|
const llmRouter = createMockLlmRouter({
|
||||||
route: vi.fn(async (_req, ctx) => {
|
route: vi.fn(async (_req, ctx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user