Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,11 @@ export function routeModel(
return route;
}

/** Resolve a combo-selected provider/model target without consulting public combo aliases again. */
export function routeConcreteModel(config: OcxConfig, modelId: string): RouteResult {
return routeModelInternal(config, modelId, true, undefined);
}

function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
if (prefixes.some(prefix => modelId.startsWith(prefix))) {
Expand Down
16 changes: 12 additions & 4 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ import {
previousResponseReplayFailure,
rememberResponseState,
} from "../../responses/state";
import { comboRouteDecisionTrace, NoEligiblePolicyCandidateError, routeModel, type RouteResult } from "../../router";
import {
comboRouteDecisionTrace,
NoEligiblePolicyCandidateError,
routeConcreteModel,
routeModel,
type RouteResult,
} from "../../router";
import { evidenceFromBody } from "../../routing/request-evidence";
import {
advanceComboAfterFailure,
Expand Down Expand Up @@ -1069,7 +1075,7 @@ export async function handleComboResponses(
const provider = config.providers[target.provider];
if (!provider || provider.disabled === true) return false;
try {
const route = routeModel(config, `${target.provider}/${target.model}`);
const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
return isCanonicalOpenAiForwardProvider(route.provider);
} catch {
return false;
Expand Down Expand Up @@ -1103,7 +1109,7 @@ export async function handleComboResponses(
...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
...(logCtx.surface ? { surface: logCtx.surface } : {}),
};
const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
const childBody = concreteComboRequestBody(
rawBody,
pick.target,
Expand Down Expand Up @@ -1469,7 +1475,9 @@ async function handleResponsesInner(

let route: RouteResult;
try {
route = routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
route = options.comboAttempt
? routeConcreteModel(config, parsed.modelId)
: routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
logCtx.routeDecision = route.routeDecision;
} catch (err) {
if (err instanceof NoAvailableComboTargetsError) {
Expand Down
29 changes: 29 additions & 0 deletions tests/server-combo-failover-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,35 @@ async function within<T>(promise: Promise<T>, ms = 2_000): Promise<T> {
}

describe("server combo failover 030 activation matrix", () => {
test("dispatches a selected concrete target despite a shadowing combo alias", async () => {
const hits: string[] = [];
const a = serve(async request => {
const body = await request.json() as { model?: string; messages?: Array<{ content?: string }> };
hits.push(`a:${body.model}:${body.messages?.[0]?.content}`);
return chatSuccess("intended", "m1");
});
const b = serve(async request => {
const body = await request.json() as { model?: string; messages?: Array<{ content?: string }> };
hits.push(`b:${body.model}:${body.messages?.[0]?.content}`);
return chatSuccess("shadow", "m2");
});
const config = comboConfig({
a: provider("openai-chat", baseUrl(a), "key-a"),
b: provider("openai-chat", baseUrl(b), "key-b"),
}, [{ provider: "a", model: "m1" }]);
config.defaultProvider = "b";
config.combos!.shadow = {
alias: "a/m1",
targets: [{ provider: "b", model: "m2" }],
};

const response = await post(config, { input: "SECRET_PROMPT_X" });

expect(response.status).toBe(200);
expect(JSON.stringify(await response.json())).toContain("intended");
expect(hits).toEqual(["a:m1:SECRET_PROMPT_X"]);
Comment thread
luvs01 marked this conversation as resolved.
});

test("ordinary openai-chat 503 hops to backup for non-stream and stream", async () => {
const hits: string[] = [];
const a = serve(async request => {
Expand Down
Loading