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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,13 @@ NEXT_PUBLIC_CLOUD_URL=
# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers).
# OMNIROUTE_TRUST_PROXY=

# Proxy addresses whose X-Forwarded-For / X-Real-IP the IP allow/deny list may believe, on top
# of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed
# only when the reverse proxy in front of OmniRoute has any other (public) address; without
# it that proxy's own address is what the filter judges. Comma-separated IPs or CIDR ranges.
# Used by: scripts/dev/peer-stamp.mjs.
# OMNIROUTE_TRUSTED_PROXIES=198.51.100.7,203.0.113.0/24

# Public callback URL for asynchronous image/audio jobs (kie.ai, etc.).
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ These **must** be set before the first run. Without them, the application will e
| `SOURCE_VERSION` | No | _(unset)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Second in the chain — set by PaaS builders (e.g. Heroku-style) as the deployed commit. |
| `NEXT_PUBLIC_SW_BUILD_ID` | No | _(derived)_ | `src/shared/components/PwaRegister.tsx` | Build-time public value the client uses to register `/sw.js?v=…`; derived from the two above, then the git SHA. |
| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. |
| `OMNIROUTE_TRUSTED_PROXIES` | No | _(unset)_ | `scripts/dev/peer-stamp.mjs` | Comma-separated IPs or CIDR ranges of reverse proxies whose `X-Forwarded-For` / `X-Real-IP` the IP allow/deny list may believe, on top of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed only for a proxy on any other public address; otherwise that proxy's own address is what the filter judges. |

### Generation Commands

Expand Down
3 changes: 2 additions & 1 deletion open-sse/config/audioRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { getProviderAlias } from "@/shared/constants/providers";
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";

interface AudioModel {
id: string;
Expand Down Expand Up @@ -658,7 +659,7 @@ function parseAudioModel(
registry: Record<string, AudioProvider>,
dynamicProviders?: AudioProvider[]
): { provider: string | null; model: string | null } {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

// Phase 1: prefix match in hardcoded registry
for (const [providerId] of Object.entries(registry)) {
Expand Down
42 changes: 35 additions & 7 deletions open-sse/config/cliFingerprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,46 @@ export function orderHeaders(
}

/**
* Apply a CLI fingerprint to headers and body.
* Returns { headers, bodyString } with the correct ordering.
* Internal request-body markers that are NOT `_omniroute*`-prefixed and must be
* removed key-by-key. Everything else is caught by INTERNAL_BODY_FIELD_PREFIX.
*/
const INTERNAL_BODY_FIELDS: readonly string[] = [
"_claudeCodeRequiresLowercaseToolNames",
"_nativeCodexPassthrough",
"_nativeXaiResponsesPassthrough",
"_nativeOpenAICompatibleResponsesPassthrough",
];

/**
* Every omniroute-owned internal marker uses this prefix, so the strip is
* prefix-based rather than an allowlist. An allowlist silently leaks each newly
* added marker to the upstream, where strict gateways reject the whole request
* (observed live: `[400]: _omnirouteSkipContextRelay: Extra inputs are not
* permitted` on a claude hop, from the context/universal-handoff markers set in
* `open-sse/services/contextHandoff.ts`). Markers are consumed by routing before
* dispatch, so removing them at this chokepoint is always safe.
*
* Deliberately narrow: only omniroute-owned prefixes are ours. A caller-sent
* field that merely starts with `_` is client payload and passes through.
*/
const INTERNAL_BODY_FIELD_PREFIX = "_omniroute";

/**
* Remove omniroute-internal markers from a request body before it is serialized
* for an upstream. Mutates and returns the same object.
*/
export function stripInternalBodyFields(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;

const record = body as Record<string, unknown>;
delete record._claudeCodeRequiresLowercaseToolNames;
delete record._nativeCodexPassthrough;
delete record._nativeXaiResponsesPassthrough;
delete record._nativeOpenAICompatibleResponsesPassthrough;
delete record._omnirouteResponsesStore;
for (const field of INTERNAL_BODY_FIELDS) {
delete record[field];
}
for (const key of Object.keys(record)) {
if (key.startsWith(INTERNAL_BODY_FIELD_PREFIX)) {
delete record[key];
}
}
return body;
}

Expand Down
4 changes: 3 additions & 1 deletion open-sse/config/embeddingRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";

/**
* Embedding Provider Registry
*
Expand Down Expand Up @@ -478,7 +480,7 @@ export function parseEmbeddingModel(
modelStr: string | null,
dynamicProviders?: EmbeddingProvider[]
): { provider: string | null; model: string | null } {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };
modelStr = applyEmbeddingModelAliases(modelStr);

// Check for "provider/model" format
Expand Down
3 changes: 2 additions & 1 deletion open-sse/config/imageRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Each provider has its own request format and endpoint.
*/

import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";
import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts";
import { SEGMIND_IMAGE_PROVIDER } from "./providers/registry/segmind/imageModels.ts";
import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts";
Expand Down Expand Up @@ -854,7 +855,7 @@ export function getImageProvider(providerId) {
* Returns { provider, model }
*/
export function parseImageModel(modelStr) {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

const directAlias = resolveImageModelAlias(modelStr);
if (directAlias) {
Expand Down
4 changes: 3 additions & 1 deletion open-sse/config/moderationRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";

/**
* Moderation Provider Registry
*
Expand Down Expand Up @@ -54,7 +56,7 @@ export function getModerationProvider(providerId: string): ModerationProvider |
* Parse a moderation model string.
*/
export function parseModerationModel(modelStr: string | null | undefined): ParsedModerationModel {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

for (const providerId of Object.keys(MODERATION_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
Expand Down
4 changes: 3 additions & 1 deletion open-sse/config/ocrRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";

/**
* OCR Provider Registry
*
Expand Down Expand Up @@ -218,7 +220,7 @@ export function getOcrProvider(providerId: string): OcrProvider | null {
* matches one of the registered OCR models.
*/
export function parseOcrModel(modelStr: string | null | undefined): ParsedOcrModel {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

for (const providerId of Object.keys(OCR_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
Expand Down
3 changes: 2 additions & 1 deletion open-sse/config/registryUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";
import { randomUUID } from "crypto";
/**
* Shared Registry Utilities
Expand Down Expand Up @@ -53,7 +54,7 @@ export function parseModelFromRegistry<P extends BaseProvider>(
modelStr: string | null,
registry: Record<string, P>
): { provider: string | null; model: string | null } {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

// Try each provider prefix
for (const [providerId, config] of Object.entries(registry)) {
Expand Down
4 changes: 3 additions & 1 deletion open-sse/config/rerankRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";

/**
* Rerank Provider Registry
*
Expand Down Expand Up @@ -168,7 +170,7 @@ export function getRerankProvider(providerId) {
* Returns { provider, model }
*/
export function parseRerankModel(modelStr) {
if (!modelStr) return { provider: null, model: null };
if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null };

const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
Expand Down
32 changes: 30 additions & 2 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export type ExecutorExecuteResult =
headers?: Record<string, string>;
transformedBody?: unknown;
transport?: string;
/** Wire model id actually sent upstream (from the serialized body). */
model?: unknown;
};

export class BaseExecutor {
Expand Down Expand Up @@ -372,6 +374,26 @@ export class BaseExecutor {
return this.getTimeoutMs();
}

/**
* Build the URL from the payload-rule-prepared body (#12826): a rule may rewrite body.model
* (custom-model alias -> real id) and URL-path providers (Gemini /models/{model}:...) must
* follow it, or Google 404s on the alias. No string body.model -> executor model (unchanged).
*/
buildUrlForBody(
model: string,
body: unknown,
stream: boolean,
urlIndex = 0,
credentials: ProviderCredentials | null = null
): string {
const bodyModel =
body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).model
: undefined;
const effectiveModel = typeof bodyModel === "string" && bodyModel ? bodyModel : model;
return this.buildUrl(effectiveModel, stream, urlIndex, credentials);
}

buildUrl(
model: string,
stream: boolean,
Expand Down Expand Up @@ -847,7 +869,7 @@ export class BaseExecutor {
body,
activeCredentials
);
const url = this.buildUrl(model, stream, urlIndex, requestCredentials);
const url = this.buildUrlForBody(model, body, stream, urlIndex, requestCredentials);
const headers = this.buildHeaders(
requestCredentials,
stream,
Expand Down Expand Up @@ -1731,7 +1753,13 @@ export class BaseExecutor {
continue;
}

return { response, url, headers: finalHeaders, transformedBody: serializedBody };
return {
response,
url,
headers: finalHeaders,
transformedBody: serializedBody,
model: (serializedBody as Record<string, unknown> | null)?.model,
};
} catch (error) {
// Distinguish timeout errors from other abort errors
const err = error instanceof Error ? error : new Error(String(error));
Expand Down
31 changes: 23 additions & 8 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
extractRequestToolIdentityMap,
extractRequestToolMetadata,
resolveResponseToolNameMap,
} from "./chatCore/requestToolIdentity.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
Expand Down Expand Up @@ -2509,8 +2509,9 @@ export async function handleChatCore({
// Keep the request translator's namespace identities separate from toolNameMap:
// the latter is a Kiro/Claude passthrough alias channel with string values,
// while namespace identities carry `{namespace, name}` for the #7936 response
// seam. Extract first because Kiro merge may reuse `_toolNameMap` below.
const requestToolIdentityMap = extractRequestToolIdentityMap(translatedBody);
// seam. Capture both before stripping their side channels: a Responses ->
// Gemini/Antigravity pivot carries both maps, not one recoverable ledger.
const { requestToolIdentityMap, toolNameAliasMap } = extractRequestToolMetadata(translatedBody);

// Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly
// formed request" for unsupported JSON-Schema keywords (anyOf/$ref/if-then,
Expand Down Expand Up @@ -2547,13 +2548,13 @@ export async function handleChatCore({
}

// Extract toolNameMap for response translation (Claude OAuth)
const translatedToolNameMap = translatedBody._toolNameMap;
const translatedToolNameMap = translatedBody._toolNameMap ?? toolNameAliasMap;
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
// Resolution order matters: `_toolNameMap` was already deleted by
// `extractRequestToolIdentityMap`, so Gemini/Antigravity depend on the
// `requestToolIdentityMap` fallback inside this helper (#9568 / #7936).
// A later provider-specific ledger (Kiro above) wins; otherwise use the
// alias map captured before extraction. Namespace identities are distinct
// from aliases and cannot restore sanitized Gemini names on their own.
const toolNameMap = resolveResponseToolNameMap(
translatedToolNameMap,
nativeClaudeToolNameMap,
Expand Down Expand Up @@ -3100,6 +3101,20 @@ export async function handleChatCore({
const res = normalizeExecutorResult(rawExecutorResult);
trace("post_executor", { status: res?.response?.status });

// When a payload override rewrote body.model (custom-model alias →
// real upstream id, e.g. `gemini-3.7-flash-high` → `gemini-3.7-flash`),
// log and track the WIRE model so dashboards/telemetry reflect what
// actually shipped and Gemini rate-limit accounting uses the real id
// (the executor already built its URL from the same rewritten model).
const wireModel =
typeof res.model === "string" && res.model ? res.model : modelToCall;
if (wireModel !== modelToCall) {
log?.debug?.(
"PAYLOAD_RULES",
`Payload rules rewrote model for URL: requested=${modelToCall} wire=${wireModel}`
);
}

if (
provider === "codex" &&
attemptConnectionId &&
Expand Down Expand Up @@ -3129,7 +3144,7 @@ export async function handleChatCore({

// Track Gemini RPM + RPD request counts for 429 classification
if (provider === "gemini") {
incrementRequestCount(modelToCall);
incrementRequestCount(wireModel);
}

updatePendingScope(pendingScope, {
Expand Down
30 changes: 1 addition & 29 deletions open-sse/handlers/chatCore/clientUsageBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
addBufferToUsage as defaultAddBuffer,
filterUsageForFormat as defaultFilterUsage,
estimateUsage as defaultEstimateUsage,
isEmptyUsage,
sanitizeProviderUsageForRequest,
type UsageLike,
} from "../../utils/usageTracking.ts";
Expand All @@ -46,35 +47,6 @@ const DEFAULT_DEPS: ClientUsageBufferDeps = {
estimateUsage: defaultEstimateUsage,
};

/** True when a usage object is present but every token field is zero/absent.
* Web/unofficial providers often emit `{prompt_tokens:0,completion_tokens:0,total_tokens:0}`
* because the upstream has no metering. Treating that as "has usage" makes
* `addBufferToUsage` turn zeros into a constant `USAGE_TOKEN_BUFFER` (default 2000),
* so every request shows exactly 2000 tokens. Prefer estimating instead. */
function isEmptyUsage(usage: unknown): boolean {
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return true;
const u = usage as Record<string, unknown>;
const fields = [
"prompt_tokens",
"completion_tokens",
"total_tokens",
"input_tokens",
"output_tokens",
"promptTokenCount",
"candidatesTokenCount",
"totalTokenCount",
];
let sawNumber = false;
for (const key of fields) {
const v = u[key];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
sawNumber = true;
if (v > 0) return false;
}
// No positive counts (or no numeric fields at all) → treat as empty.
return true;
}

/** context_budget_* → visible-field mapping folded back in for Claude-Code-compatible
* responses only (see module docstring above). */
const CONTEXT_BUDGET_TO_VISIBLE_FIELD: Record<string, string> = {
Expand Down
31 changes: 1 addition & 30 deletions open-sse/handlers/chatCore/jsonBodyToSse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
import { withBodyTimeout as defaultWithBodyTimeout } from "../../utils/stream.ts";
import { synthesizeOpenAiSseFromJson as defaultSynthesize } from "../../utils/jsonToSse.ts";
import { prependBufferedChunks } from "../../utils/streamReadiness.ts";

type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;

Expand All @@ -28,36 +29,6 @@ const DEFAULT_DEPS: JsonBodyToSseDeps = {
synthesizeOpenAiSseFromJson: defaultSynthesize,
};

function prependBufferedChunks(
chunks: Uint8Array[],
reader: ReadableStreamDefaultReader<Uint8Array>
): ReadableStream<Uint8Array> {
let index = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index++]);
return;
}
try {
const { done, value } = await reader.read();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
} catch (error) {
controller.error(error);
}
},
async cancel(reason) {
try {
await reader.cancel(reason);
} catch {}
},
});
}

function classifyBodyPrefix(text: string): "sse" | "non-sse" | "unknown" {
const trimmed = text.replace(/^\uFEFF/, "").trimStart();
if (!trimmed) return "unknown";
Expand Down
Loading
Loading