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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(guardrails/chat): do not whole-request-reroute Vision Bridge away from credentialed models (e.g. combo target zai/glm-5.2 or grok-cli → opencode-zen noauth 401); align body.model with X-Route-Model so post-guardrail cannot undo the routing header
158 changes: 130 additions & 28 deletions src/lib/guardrails/visionBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,79 @@ export interface VisionBridgeDependencies {
) => Promise<string>;
/** Override combo-target vision check — return true to force processing, false to skip. */
checkModelHasComboMapping?: (model: string) => Promise<boolean>;
/**
* Whether a model string has a usable active credential (true/false).
* Return `null` when indeterminate (no DB / error) so callers can fail-open.
*/
hasUsableCredentials?: (model: string) => Promise<boolean | null>;
}

/**
* True when a provider connection can actually authenticate upstream.
* `noauth` with no real API key is NOT usable (opencode-zen free tier often
* surfaces as noauth and then 401 "Missing API key").
*/
type ProviderConnectionLike = {
authType?: string | null;
apiKey?: string | null;
accessToken?: string | null;
refreshToken?: string | null;
idToken?: string | null;
testStatus?: string | null;
};

const TERMINAL_CONNECTION_STATUSES = new Set(["disabled", "banned", "expired"]);
// Free/noauth only counts when a real key is still present; apikey/cookie need the same.
const KEY_ONLY_AUTH_TYPES = new Set(["noauth", "none", "", "apikey", "cookie"]);
const TOKEN_AUTH_TYPES = new Set(["oauth", "access_token", "external_idp"]);

function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}

function hasOAuthCredential(connection: ProviderConnectionLike): boolean {
return (
hasNonEmptyString(connection.refreshToken) ||
hasNonEmptyString(connection.accessToken) ||
hasNonEmptyString(connection.idToken)
);
}

export function isProviderConnectionUsable(connection: ProviderConnectionLike): boolean {
const status = String(connection.testStatus || "").toLowerCase();
if (TERMINAL_CONNECTION_STATUSES.has(status)) {
return false;
}

const auth = String(connection.authType || "").toLowerCase();
const hasKey = hasNonEmptyString(connection.apiKey);

if (KEY_ONLY_AUTH_TYPES.has(auth)) {
return hasKey;
}
if (TOKEN_AUTH_TYPES.has(auth)) {
return hasOAuthCredential(connection) || hasKey;
}
return hasKey;
}

/**
* Resolve whether `provider/model` has at least one usable active connection.
* Returns `null` when the credential store is unavailable (unit tests / early boot).
*/
export async function hasUsableCredentialsForModel(model: string): Promise<boolean | null> {
const provider = typeof model === "string" ? model.split("/")[0]?.trim() : "";
if (!provider) return null;
try {
const { getProviderConnections } = await import("@/lib/db/providers");
const connections = await getProviderConnections({ provider, isActive: true });
if (!Array.isArray(connections)) return null;
// Empty active set is a definitive "no" only when the table is readable.
if (connections.length === 0) return false;
return connections.some((c: any) => isProviderConnectionUsable(c));
} catch {
return null;
}
}

export class VisionBridgeGuardrail extends BaseGuardrail {
Expand Down Expand Up @@ -183,37 +256,66 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
return { block: false };
}

// 9. Individual non-combo model with images → REROUTE to best vision-capable model
// 9. Individual non-combo model with images → optionally REROUTE to best vision-capable model
// instead of describing images through an intermediate vision call.
// This lets a downstream vision model process the image natively.
//
// CRITICAL (VibeProxy combo / explicit provider models):
// When the original model already has usable credentials (e.g. combo target
// zai/glm-5.2), NEVER whole-request-reroute to another provider. Auto-select
// prefers opencode-* (priority 0) even when only a broken noauth connection
// exists, which produced: HTTP log zai → Guardrail reroute → opencode-zen 401
// "Missing API key" while the combo UI still showed body=zai. Fall through to
// the image-describe path so the user's chosen model still answers.
if (comboVisionBridgeDecision === "not-combo" && !forceVisionBridge) {
// Honor an explicit operator override from the Vision Bridge settings tab
// (settings.visionBridgeModel) as the fixed reroute target, for consistency
// with the combo/describe path below (step 10) which always honors it via
// getVisionBridgeConfig. When unset, auto-select the fastest available
// vision-capable model from available providers.
const configuredModel =
typeof settings.visionBridgeModel === "string" && settings.visionBridgeModel.trim()
? settings.visionBridgeModel.trim()
: undefined;
const bestModel = getBestVisionModel({ fixedModel: configuredModel });
if (bestModel && bestModel !== model) {
const modifiedBody = {
...(body as Record<string, unknown>),
model: bestModel,
};
return {
block: false,
modifiedPayload: modifiedBody as unknown,
meta: {
rerouted: true,
fromModel: model,
toModel: bestModel,
imagesKept: imageParts.length,
},
};
const checkCreds =
this.deps.hasUsableCredentials ?? hasUsableCredentialsForModel;
const originalUsable = await checkCreds(model);

if (originalUsable === true) {
// Keep the credentialed model; describe images below if needed.
context.log?.debug?.(
"VISION_BRIDGE",
`Skipping whole-request vision reroute; keeping credentialed model ${model}`
);
} else {
// Honor an explicit operator override from the Vision Bridge settings tab
// (settings.visionBridgeModel) as the fixed reroute target, for consistency
// with the combo/describe path below (step 10) which always honors it via
// getVisionBridgeConfig. When unset, auto-select the fastest available
// vision-capable model from available providers.
const configuredModel =
typeof settings.visionBridgeModel === "string" && settings.visionBridgeModel.trim()
? settings.visionBridgeModel.trim()
: undefined;
const bestModel = getBestVisionModel({ fixedModel: configuredModel });
if (bestModel && bestModel !== model) {
const bestUsable = await checkCreds(bestModel);
// Only block the reroute when we KNOW the target is unusable (false).
// `null` (no DB / tests) fails open so existing unit tests keep working.
if (bestUsable === false) {
context.log?.warn?.(
"VISION_BRIDGE",
`Vision reroute target ${bestModel} has no usable credentials; describing images instead of hijacking ${model}`
);
} else {
const modifiedBody = {
...(body as Record<string, unknown>),
model: bestModel,
};
return {
block: false,
modifiedPayload: modifiedBody as unknown,
meta: {
rerouted: true,
fromModel: model,
toModel: bestModel,
imagesKept: imageParts.length,
},
};
}
}
}
// Fall through: if no vision model found, describe images as text instead
// Fall through: describe images as text (or no-op if describe path can't run)
}

// 10. Get configuration
Expand Down
15 changes: 10 additions & 5 deletions src/lib/guardrails/visionBridgeRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,17 @@ function getVisionCapableModels(): VisionModelCandidate[] {
const caps = getResolvedModelCapabilities(fullModelId);

if (caps.supportsVision === true) {
// Determine priority based on provider type
// Determine priority based on provider type (lower = better).
// Do NOT prefer opencode-* first: those catalog entries often resolve to a
// noauth connection and 401 "Missing API key", hijacking working providers
// (e.g. zai/glm-5.2 combo targets) when Vision Bridge auto-reroutes.
let priority = 100;
if (providerAlias.startsWith("opencode-")) {
priority = 0; // Local/free models first
} else if (providerAlias === "openai" || providerAlias === "anthropic") {
priority = 50; // Major providers
if (providerAlias === "openai" || providerAlias === "anthropic") {
priority = 50; // Major providers with real API keys
} else if (providerAlias === "vertex" || providerAlias === "gemini") {
priority = 55;
} else if (providerAlias.startsWith("opencode-")) {
priority = 95; // Free/catalog — only if nothing credentialed is available
} else {
priority = 75; // Other providers
}
Expand Down
43 changes: 22 additions & 21 deletions src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "crypto";
import { resolveChatRequestBody } from "./requestBody";
import { normalizeReasoningRequest } from "@/shared/reasoning/effortStandardization";
import { resolveRoutingModel } from "./resolveRoutingModel";
import { resolveRoutingModel, RoutingModelOps } from "./resolveRoutingModel";
import {
getProviderCredentialsWithQuotaPreflight,
markAccountUnavailable,
Expand Down Expand Up @@ -368,6 +368,8 @@ export async function handleChat(
// resolveRoutingModel). The resolved model still passes through
// enforceApiKeyPolicy below, so it cannot bypass per-key allowlists.
let modelStr = resolveRoutingModel(request, body);
// Align body.model with the routing model immediately (see applyRoutingModelAlignment).
body = RoutingModelOps.align(body, modelStr, log);

// Count messages (support both messages[] and input[] formats)
const msgCount = body.messages?.length || body.input?.length || 0;
Expand Down Expand Up @@ -470,24 +472,19 @@ export async function handleChat(
preCallGuardrails.message || "Request rejected: suspicious content detected"
);
}
// Snapshot model BEFORE the guardrail payload (see reconcileGuardrailReroute).
const modelBeforeGuardrails =
typeof body?.model === "string" && body.model.length > 0 ? body.model : modelStr;
body = preCallGuardrails.payload;
if (body?.model && typeof body.model === "string" && body.model !== modelStr) {
const rerouteModel = body.model;
// A guardrail (e.g. Vision Bridge auto-reroute) can swap body.model AFTER
// enforceApiKeyPolicy already validated modelStr's allowlist/budget above.
// Re-check the new target against the same per-key allowlist so a
// policy-restricted key cannot be silently routed to an unchecked model.
const rerouteAllowed = await isModelAllowedForKey(apiKey, rerouteModel);
if (!rerouteAllowed) {
log.warn(
"POLICY",
`Guardrail reroute to "${rerouteModel}" rejected by API key policy (key=${apiKeyInfo?.id || "unknown"}); keeping original model "${modelStr}"`
);
body = { ...body, model: modelStr };
} else {
modelStr = rerouteModel;
}
}
({ body, modelStr } = await RoutingModelOps.reconcileGuardrailReroute({
body,
modelBeforeGuardrails,
modelStr,
apiKey,
apiKeyId: apiKeyInfo?.id,
isModelAllowedForKey,
log,
}));
telemetry.endPhase();

// T08: per-key active session limit (0 = unlimited).
Expand Down Expand Up @@ -528,9 +525,13 @@ export async function handleChat(

// Apply hook mutations
body = hookCtx.body as any;
if (hookCtx.model && hookCtx.model !== modelStr) {
modelStr = hookCtx.model;
}
({ body, modelStr } = RoutingModelOps.reconcileModelOverride({
body,
modelStr,
overrideModel: hookCtx.model,
logTag: "Hook model override",
log,
}));

// Short-circuit if a hook returned a direct response
if (hookResponse) {
Expand Down
Loading
Loading