Integration: security tier + self-hosted operator blockers (rebased onto v3.8.51) (#10952)

Validated on the resolved merge against the current tip (527da656 + the post-#11281 rebaseline): the single conflict was a comment-only collision in providers/[id]/models/route.ts (kept the tip's #10828-ordering note). Focused suites 125/125 across all 13 touched test files (build-sqlite-stub, cc-compatible, copilot-claude-messages, copilot-gemini-route, executor-github, ghe-copilot, github-copilot-discovery-token, github-copilot-model-discovery, noauth-sibling-7620, provider-header-profiles, provider-models-config, request-log-payloads, upstream-error-passthrough), typecheck:core clean, file-size/changelog-integrity OK. Merged --admin over the inherited 2026-08-23 base-red cluster (#9985) — the reds are proven tip failures (CLI catalog cluster + @testing-library allowlist, being drained by #11280), not from this diff. Note: the rebase means several items the body listed (relay x-relay-path SSRF, /v1/search blocked-providers, #10736 rotation fence, #10903, #10865, #10899, #10916) already landed upstream and are NOT in this delta — the delta is: better-sqlite3 build guard + build heap/worker caps + telemetry-off (#10060 re-derived), credential-echo passthrough refusal + OCR/moderation redaction + call-log key redaction, Copilot CLI 1.0.81-6 wire identity + Claude→/v1/messages name-matched routing + discovery token fix, CC model_not_found 400, compat overrides for no-auth aliases (#7620-pinned). The Copilot wire-identity change is the one to watch in production. Thank you @arminanton — and the ported-author credits in the commit history (@rqzbeh, yidecode, the #10899/#10916 authors) are preserved. Your config-posture finding (REQUIRE_API_KEY default vs 0.0.0.0) is noted for a maintainer decision, as you scoped it.
This commit is contained in:
Armin Anton” ∴
2026-08-23 12:51:25 -07:00
committed by GitHub
parent bdf63d2171
commit 10276821cd
41 changed files with 1157 additions and 163 deletions

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
# Shell scripts must always be checked out with LF line endings.
#
# On Windows, core.autocrlf=true converts text files to CRLF in the working
# tree. Scripts that are kernel-exec'd (Docker ENTRYPOINT, bin/*.sh on Linux
# hosts) then fail with `exec ...: no such file or directory` because the
# shebang becomes "#!/bin/sh\r". eol=lf overrides autocrlf for these files.
*.sh text eol=lf
# This file must stay LF too: git parses it as-is, and a trailing CR would
# corrupt every pattern (e.g. "*.sh\r" matches nothing).
.gitattributes text eol=lf

View File

@@ -59,6 +59,12 @@ RUN set -eux; \
# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder
# No telemetry, anywhere. Disable Next.js's anonymous build-time telemetry
# (it otherwise pings Vercel during `next build`). Set on the builder stage so
# every image build is silent; the runtime never builds, so this covers the
# only phase Next telemetry can fire.
ENV NEXT_TELEMETRY_DISABLED=1
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
@@ -166,9 +172,20 @@ ENV OMNIROUTE_MITM_STUB=1
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
# Build-only; the runtime heap is set separately on the runner stage
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
# Default raised 4096 → 6144 (#10060): the Next 16 production pass on a codebase
# this size intermittently OOMs a build worker at 4 GB on memory-tight hosts.
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
# Cap Next.js build worker pools. Next 16 defaults to `os.cpus().length - 1`
# workers for page-data collection (31 on a 32-core builder); on memory-tight
# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies
# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"),
# silently leaving no standalone bundle. Next derives the default worker count
# from CIRCLE_NODE_TOTAL (workers = N-1), so N=8 → 7 workers: fast enough while
# fitting comfortably in RAM on any host. (#10060)
ENV CIRCLE_NODE_TOTAL=8
COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \

View File

@@ -0,0 +1 @@
- **fix(build):** stop the native `better-sqlite3` addon from loading during the Next.js production build (#10060). Its `Statement` destructor aborts with `SIGABRT` when a build worker thread exits (assertion in `node::RemoveEnvironmentCleanupHook`, `env == nullptr`), which can leave the build with no standalone bundle. Every DB entry point now keys off a reliable `OMNIROUTE_BUILDING=1` signal (set by `build-next-isolated.mjs` and inherited by every spawned build worker, because Next.js workers sometimes drop `NEXT_PHASE`): `getDbInstance()` returns a no-op SQLite stub during build, `driverFactory` skips the native driver and falls through to `node:sqlite`, and the `codegraph`/`kiro-import` lazy loaders fail closed. A build-time `better-sqlite3` alias to a stub (`next.config.mjs`, turbopack) backs this up without changing runtime behaviour (the real package is still `require()`d natively via `serverExternalPackages`). Also raises the default build heap 4096→6144 MB and caps Next build worker pools (`CIRCLE_NODE_TOTAL=8`) to avoid the many-core page-data-collection SIGSEGV, and adds `.gitattributes` (`*.sh text eol=lf`) so kernel-exec'd shell scripts never ship with CRLF shebangs. Deliberately does NOT downgrade the Node base image: per the maintainer's review on #10060, `release/v3.8.50` moved to `node:26-trixie-slim` through several considered commits, so the `OMNIROUTE_BUILDING` guard is re-derived against the current base rather than reverting the FROM line; the npm pin and binary-hide dance from the original PR are dropped because our build already rebuilds `better-sqlite3` deterministically via `node-gyp` and floats `npm@latest` for the CVE overlay.

View File

@@ -0,0 +1 @@
- **fix(security):** harden three secret-leak paths surfaced by an audit of the error/log surface. (1) `upstreamErrorPassthrough` relays an upstream provider's 4xx body verbatim to Claude-Code-format clients (the capability-recovery contract needs the exact wording); it now refuses passthrough when the body actually carries a credential pattern (`Bearer`/`Basic` token, `sk-…`, or an `api_key`/`token`/`authorization`/`cookie`/`secret` assignment) so a provider that echoes the offending request can't relay a key to the client, falling back to the sanitized error path. The credential regex is bounded (ReDoS-safe, verified linear at 60k chars). (2) The OCR and moderations handlers no longer forward an upstream error body byte-for-byte; they run it through the (now exported) structure-preserving `redactSensitiveErrorText` first. (3) `protectPayloadForLog`'s sensitive-key set gains `cookie`/`storageState`/`runtimeKey`/`capability` so web-impersonation credentials (Meta AI `ecto_1_sess`, chatgpt-web `storageState`) that land in a request/response body field are redacted before the call-log artifact is written to disk. No behavior change for secret-free error bodies; the Claude Code verbatim-wording contract is preserved.

View File

@@ -138,6 +138,10 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

View File

@@ -1,16 +1,41 @@
import { getAntigravityContentHeaders } from "../services/antigravityHeaders.ts";
import type { AntigravityClientProfile } from "@/shared/constants/antigravityClientProfile";
export const GITHUB_COPILOT_API_VERSION = "2026-06-01";
export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.126.0";
export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = "copilot-chat/0.54.0";
export const GITHUB_COPILOT_CHAT_USER_AGENT = "GitHubCopilotChat/0.54.0";
export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = "copilot/1.388.0";
// GitHub Copilot request identity. Ported to match the GitHub Copilot CLI
// (`copilot` npm package) wire identity that Hermes captured live, NOT the
// VS Code Copilot Chat extension. The CLI's `copilot-developer-cli` integration
// id is the catalog-unlock lever: it exposes the full entitled model set
// (gemini-3.x, gpt-5.4-nano, the full opus reasoning range) where `vscode-chat`
// returns a narrower list. Version strings track the live-captured CLI 1.0.81-6.
export const GITHUB_COPILOT_API_VERSION = "2026-08-01";
export const GITHUB_COPILOT_CLI_VERSION = "1.0.81-6";
export const GITHUB_COPILOT_EDITOR_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CHAT_PLUGIN_VERSION = `copilot-chat/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CHAT_USER_AGENT = `GitHubCopilotChat/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_CLI_USER_AGENT = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_REFRESH_PLUGIN_VERSION = `copilot/${GITHUB_COPILOT_CLI_VERSION}`;
export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0";
export const GITHUB_COPILOT_INTEGRATION_ID = "vscode-chat";
export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-panel";
export const GITHUB_COPILOT_INTEGRATION_ID = "copilot-developer-cli";
export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-agent";
export const GITHUB_COPILOT_INTERACTION_TYPE = "conversation-user";
export const GITHUB_COPILOT_HARNESS_ID = "copilot-sdk";
export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user";
export const GITHUB_COPILOT_USER_AGENT_LIBRARY = "electron-fetch";
// Stable per-install device fingerprint (the CLI's X-Client-Machine-Id). The
// real @github/copilot CLI sends ONE stable UUID on every inference + /models
// call (verified identical across all captured requests) — a per-call random id
// would itself be an anti-fingerprint tell. We mint one per process and cache
// it (env-overridable via GITHUB_COPILOT_MACHINE_ID), which keeps it stable for
// the lifetime of a running OmniRoute instance, matching "one CLI install".
let _copilotMachineId: string | null = null;
export function getGitHubCopilotMachineId(): string {
const override = (process?.env?.GITHUB_COPILOT_MACHINE_ID || "").trim();
if (override) return override;
if (_copilotMachineId) return _copilotMachineId;
_copilotMachineId =
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return _copilotMachineId;
}
export const QWEN_CLI_VERSION = "0.19.3";
export const QWEN_STAINLESS_LANG = "js";
@@ -26,20 +51,36 @@ export const CURSOR_REGISTRY_VERSION = "3.9";
export function getGitHubCopilotChatHeaders(
accept = "application/json",
initiator = GITHUB_COPILOT_DEFAULT_INITIATOR
initiator = GITHUB_COPILOT_DEFAULT_INITIATOR,
options: { vision?: boolean; intent?: string } = {}
): Record<string, string> {
return {
// Matches the live @github/copilot CLI 1.0.81-6 inference request 1:1 (MITM-
// captured). NOTE the CLI does NOT send `editor-plugin-version` nor
// `x-vscode-user-agent-library-version` on the inference path — those belong
// to the VS Code Copilot Chat extension, not the CLI. Sending an incomplete
// OR an over-complete header fingerprint is itself a flagging signal, so we
// send exactly the CLI's set. The `copilot-integration-id` (copilot-developer-cli)
// is the catalog-unlock lever; the stable X-Client-Machine-Id is the CLI's
// per-install device fingerprint.
const headers: Record<string, string> = {
"copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID,
"editor-version": GITHUB_COPILOT_EDITOR_VERSION,
"editor-plugin-version": GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
"user-agent": GITHUB_COPILOT_CHAT_USER_AGENT,
"openai-intent": GITHUB_COPILOT_OPENAI_INTENT,
"user-agent": GITHUB_COPILOT_CLI_USER_AGENT,
"openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT,
"x-interaction-type": GITHUB_COPILOT_INTERACTION_TYPE,
"copilot-harness-id": GITHUB_COPILOT_HARNESS_ID,
"x-github-api-version": GITHUB_COPILOT_API_VERSION,
"x-vscode-user-agent-library-version": GITHUB_COPILOT_USER_AGENT_LIBRARY,
"x-client-machine-id": getGitHubCopilotMachineId(),
"X-Initiator": initiator,
Accept: accept,
"Content-Type": "application/json",
};
// Copilot's /v1/messages proxy returns an empty content block for image
// requests unless this is set. Add it only when the turn carries an image.
if (options.vision) {
headers["copilot-vision-request"] = "true";
}
return headers;
}
export function getRuntimePlatform(): string {

View File

@@ -16,6 +16,11 @@ export const gheCopilotProvider: RegistryEntry = {
forceStream: true,
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
// Anthropic-native /v1/messages shim for Claude models. Static default only;
// the GHE executor's getMessagesBase() derives the real per-connection host
// from copilotApiUrl/gheUrl at request time. Its presence enables Claude ->
// /v1/messages routing in the buildUrl override.
messagesUrl: "https://api.githubcopilot.com/v1/messages",
authType: "oauth",
authHeader: "bearer",
// GHE Copilot requires a custom gheUrl (set per-connection via providerSpecificData).

View File

@@ -74,6 +74,13 @@ export const githubProvider: RegistryEntry = {
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-opus-4.6",
name: "Claude Opus 4.6",
targetFormat: "claude",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
@@ -122,6 +129,18 @@ export const githubProvider: RegistryEntry = {
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "gemini-3.6-flash",
name: "Gemini 3.6 Flash",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
contextLength: 1000000,
maxOutputTokens: 64000,
},
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
@@ -156,6 +175,13 @@ export const githubProvider: RegistryEntry = {
contextLength: 400000,
maxOutputTokens: 128000,
},
{
id: "gpt-5.4-nano",
name: "GPT-5.4 nano",
targetFormat: "openai-responses",
contextLength: 400000,
maxOutputTokens: 128000,
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3-Codex",
@@ -196,6 +222,38 @@ export const githubProvider: RegistryEntry = {
contextLength: 256000,
maxOutputTokens: 128000,
},
// MAI (Microsoft AI) — /responses-only on Copilot (400 on /chat/completions).
{
id: "mai-code-1.1-flash",
name: "MAI-Code-1.1-Flash",
targetFormat: "openai-responses",
contextLength: 256000,
maxOutputTokens: 128000,
},
{
id: "mai-code-1-flash-picker",
name: "MAI-Code-1-Flash (picker)",
targetFormat: "openai-responses",
contextLength: 256000,
maxOutputTokens: 128000,
},
// xAI Grok on Copilot — /responses-only (supported_endpoints: ["/responses"];
// 400 on /chat/completions). Distinct from xAI-direct (chat-capable) — see
// the separate `xai` provider. Live-verified context 500k / output 128k.
{
id: "grok-4.6",
name: "Grok 4.6",
targetFormat: "openai-responses",
contextLength: 500000,
maxOutputTokens: 128000,
},
{
id: "grok-4.5",
name: "Grok 4.5",
targetFormat: "openai-responses",
contextLength: 500000,
maxOutputTokens: 128000,
},
{
id: "oswe-vscode-prime",
name: "Raptor mini",

View File

@@ -15,6 +15,10 @@ export class GheCopilotExecutor extends GithubExecutor {
format: "openai",
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesBaseUrl: "https://api.githubcopilot.com/responses",
// Static default only; the executor's getMessagesBase() derives the real
// per-connection host from copilotApiUrl/gheUrl at request time. Its
// presence enables Claude -> /v1/messages routing in the buildUrl override.
messagesUrl: "https://api.githubcopilot.com/v1/messages",
authType: "oauth",
authHeader: "bearer",
...config,
@@ -70,6 +74,29 @@ export class GheCopilotExecutor extends GithubExecutor {
return `${base}/responses`;
}
/**
* Derive the base URL for the Anthropic-native /v1/messages shim from the GHE
* host in providerSpecificData. Claude models use this endpoint (prompt-cache
* token counts + lossless tool_use/tool_result/thinking blocks) rather than
* the OpenAI-shaped /chat/completions. Appends /v1/messages if not present.
*/
private getMessagesBase(credentials: ProviderCredentials | null): string {
const psd = credentials?.providerSpecificData;
const apiOrProxy =
(typeof psd?.copilotApiUrl === "string" ? psd.copilotApiUrl : undefined) ||
(typeof psd?.copilotProxyUrl === "string" ? psd.copilotProxyUrl : undefined);
const host = apiOrProxy || (psd?.gheUrl as string | undefined);
if (!host) {
throw new Error("GHE Copilot executor requires copilotApiUrl or gheUrl in providerSpecificData");
}
const base = host
.replace(/\/v1\/messages\/?$/, "")
.replace(/\/chat\/completions\/?$/, "")
.replace(/\/responses\/?$/, "")
.replace(/\/+$/, "");
return `${base}/v1/messages`;
}
/**
* Strip the `ghe-copilot/` provider prefix from a model id so the upstream
* GHE Copilot proxy receives the bare id (e.g. `gpt-5-mini`).
@@ -83,6 +110,13 @@ export class GheCopilotExecutor extends GithubExecutor {
override buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: ProviderCredentials | null = null): string {
const bareModel = this.stripPrefix(model);
const targetFormat = getModelTargetFormat("ghe-copilot", bareModel);
// Claude models: ALWAYS route to the Anthropic-native /v1/messages shim
// (same as github.com Copilot), matched on the model NAME so a Claude id
// that is missing its registry targetFormat tag still gets the native shim
// instead of the lossy /chat/completions path.
if ((targetFormat === "claude" || /claude/i.test(bareModel)) && this.config.messagesUrl) {
return this.getMessagesBase(credentials);
}
if (
(targetFormat === "openai-responses" || /codex/i.test(bareModel)) &&
this.supportsResponsesEndpoint(bareModel)

View File

@@ -84,14 +84,17 @@ export class GithubExecutor extends BaseExecutor {
typeof overrideTargetFormat === "string"
? overrideTargetFormat
: getModelTargetFormat("gh", model);
// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the
// only Copilot endpoint that surfaces prompt-cache token counts for Claude and
// avoids a lossy round-trip of tool_use/tool_result/thinking content blocks
// through the OpenAI shape. Driven by the registry's per-model targetFormat
// (see registry/github/index.ts), which chatCore.ts also uses to translate the
// request to Claude shape before the executor ever sees it.
// Claude models: ALWAYS route to Copilot's Anthropic-native /v1/messages
// shim — the only Copilot endpoint that surfaces prompt-cache token counts
// for Claude and avoids a lossy round-trip of tool_use/tool_result/thinking
// content blocks through the OpenAI shape. Matched on the model NAME (not
// only the registry's per-model targetFormat) so a Claude model that is
// missing its targetFormat tag, or a custom Claude id, still gets the native
// shim rather than silently falling through to /chat/completions. Mirrors
// the Hermes copilot routing (`if "claude" in model: return CAPI_MESSAGES_URL`).
// Port of decolua/9router#2608 (author: yidecode).
if (targetFormat === "claude" && this.config.messagesUrl) {
const isClaudeModel = /claude/i.test(model || "");
if ((targetFormat === "claude" || isClaudeModel) && this.config.messagesUrl) {
return this.config.messagesUrl;
}
// 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"]
@@ -329,16 +332,70 @@ export class GithubExecutor extends BaseExecutor {
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
// CLI 1.0.81-6 puts on every inference request (MITM-captured). The machine
// id (getGitHubCopilotMachineId) is stable per-install; these three are
// fresh uuids. A Copilot-aware client may pin the session/task ids across a
// conversation via its own headers — honor those when present, else mint.
const genId = () =>
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
headers["x-client-session-id"] =
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();
headers["x-agent-task-id"] =
this.readClientHeader(clientHeaders, "x-agent-task-id") || genId();
// Repository correlation sentinels. The CLI sends the working repo's nwo/host
// or these literals when there is no repository context. OmniRoute is not
// repo-scoped, so forward a client-supplied value when present, else sentinel.
headers["x-github-repository-nwo"] =
this.readClientHeader(clientHeaders, "x-github-repository-nwo") || "__no_repository__";
headers["x-github-repository-host"] =
this.readClientHeader(clientHeaders, "x-github-repository-host") || "__no_repository__";
// OpenAI-SDK (stainless) signature the CLI carries on streamed turns only.
if (stream) {
headers["x-stainless-helper-method"] = "stream";
}
// Claude models routed to the Anthropic-native /v1/messages shim require the
// anthropic-version header (harmless no-op on /chat/completions and /responses,
// but /v1/messages rejects the request without it). Port of decolua/9router#2608.
if (model && getModelTargetFormat("gh", model) === "claude") {
// but /v1/messages rejects the request without it). Match on the model NAME so
// it fires for every claude-* id (tagged or not), consistent with buildUrl.
// Port of decolua/9router#2608.
if (model && /claude/i.test(model)) {
headers["anthropic-version"] = "2023-06-01";
}
// Forward a vision signal when the client already set it. Copilot's
// /v1/messages proxy returns an empty content block for image turns unless
// copilot-vision-request:true is present; a Copilot-aware harness that sends
// it should have it honored rather than stripped.
if ((this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() === "true") {
headers["copilot-vision-request"] = "true";
}
return headers;
}
// Case-insensitive read of a single client header value. Client header maps
// arrive with inconsistent casing depending on the transport, so match on the
// lowercased key rather than assuming a canonical form.
private readClientHeader(
clientHeaders: Record<string, string> | null | undefined,
name: string
): string | null {
if (!clientHeaders) return null;
const target = name.toLowerCase();
const direct = clientHeaders[name] ?? clientHeaders[target];
if (typeof direct === "string") return direct;
for (const key in clientHeaders) {
if (key.toLowerCase() === target) {
const val = clientHeaders[key];
return typeof val === "string" ? val : null;
}
}
return null;
}
// Forward the client's x-initiator header when present. OpenCode and other
// Copilot-aware clients use this to distinguish user-initiated turns
// (x-initiator: user) from autonomous tool-call continuations

View File

@@ -6,7 +6,7 @@ import { CORS_HEADERS } from "../utils/cors.ts";
*/
import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.ts";
import { errorResponse } from "../utils/error.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
@@ -57,7 +57,9 @@ export async function handleModeration({ body, credentials }) {
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
// secret-leak hardening: redact any credential the upstream echoed back
// before relaying the error body to the client (structure-preserving).
return new Response(redactSensitiveErrorText(errText), {
status: res.status,
headers: {
"Content-Type": "application/json",

View File

@@ -11,7 +11,7 @@ import {
parseOcrModel,
OCR_PROVIDERS,
} from "../config/ocrRegistry.ts";
import { errorResponse } from "../utils/error.ts";
import { errorResponse, redactSensitiveErrorText } from "../utils/error.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import {
@@ -151,7 +151,10 @@ export async function handleOcr({
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
// secret-leak hardening: an upstream OCR provider can echo the offending
// request (Authorization header / api key) inside its error text. Redact
// secret patterns (structure-preserving) before relaying to the client.
return new Response(redactSensitiveErrorText(errText), {
status: res.status,
headers: {
"Content-Type": "application/json",

View File

@@ -20,12 +20,20 @@
import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts";
export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models";
export const GITHUB_COPILOT_MODEL_ALLOWLIST = [
// Static fallback catalog. Used ONLY when live discovery is unavailable
// (offline / unauthed / upstream error): the account's real entitlements can't
// be read, so we fall back to this curated set of known-good chat ids. It is
// NOT used to gate the LIVE response — see parseGitHubCopilotModels, which keeps
// every entitled chat model the catalog returns (so newly-entitled models like
// grok-4.6 / mai-code-1.1-flash / gemini-3.6-flash appear without a code edit).
export const GITHUB_COPILOT_STATIC_FALLBACK_MODELS = [
"claude-fable-5",
"claude-opus-5",
"claude-opus-4.8-fast",
"claude-opus-4.8",
"claude-opus-4.7",
"claude-opus-4.6",
"claude-sonnet-4.6",
"claude-opus-4.5",
"claude-sonnet-5",
@@ -33,12 +41,15 @@ export const GITHUB_COPILOT_MODEL_ALLOWLIST = [
"claude-haiku-4.5",
"gemini-3.1-pro-preview",
"gemini-3.7-flash",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"gpt-5-mini",
"gpt-4o-2024-11-20",
@@ -46,10 +57,18 @@ export const GITHUB_COPILOT_MODEL_ALLOWLIST = [
"gpt-4-0125-preview",
"kimi-k2.7-code",
"mai-code-1-flash",
"mai-code-1.1-flash",
"mai-code-1-flash-picker",
"grok-4.6",
"grok-4.5",
"oswe-vscode-prime",
] as const;
const GITHUB_COPILOT_MODEL_ALLOWLIST_SET = new Set<string>(GITHUB_COPILOT_MODEL_ALLOWLIST);
// Back-compat alias: earlier code + tests imported this name. It is now the
// static FALLBACK catalog, not a live-response gate.
export const GITHUB_COPILOT_MODEL_ALLOWLIST = GITHUB_COPILOT_STATIC_FALLBACK_MODELS;
const GITHUB_COPILOT_STATIC_FALLBACK_SET = new Set<string>(GITHUB_COPILOT_STATIC_FALLBACK_MODELS);
export type GitHubCopilotModel = {
id: string;
@@ -69,10 +88,47 @@ function toNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
// Decide whether a live /models row is a routable chat model. Capability-driven
// (rename-robust) rather than an id allowlist: any model the account is entitled
// to whose capabilities.type is "chat" (or that carries a chat-shaped
// supported_endpoints) is kept, so a newly-entitled model shows up with no code
// change. Only explicitly non-chat rows (embeddings / completion) are dropped.
function isRoutableChatModel(item: RawRecord): boolean {
const capabilities = asRecord(item.capabilities);
const capType = toNonEmptyString(capabilities.type);
if (capType) return capType === "chat";
// No capabilities.type present — fall back to supported_endpoints shape. A
// chat model exposes /chat/completions, /responses, or /v1/messages.
const endpoints = Array.isArray(item.supported_endpoints)
? (item.supported_endpoints as unknown[])
: Array.isArray((asRecord(item.capabilities) as RawRecord).supported_endpoints)
? ((asRecord(item.capabilities) as RawRecord).supported_endpoints as unknown[])
: [];
if (endpoints.length > 0) {
return endpoints.some((e) => {
const s = toNonEmptyString(e) || "";
return (
s.includes("/chat/completions") || s.includes("/responses") || s.includes("/v1/messages")
);
});
}
// Neither signal present: keep it unless its id looks like a known non-chat
// utility (embedding / completion sentinels). This keeps discovery permissive
// without re-introducing a brittle positive allowlist.
const id = (toNonEmptyString(item.id) || toNonEmptyString(item.model) || "").toLowerCase();
if (!id) return false;
return !(id.includes("embedding") || id === "gpt-41-copilot");
}
/**
* Parse a Copilot `/models` response into managed model rows. Only ids present
* in the live response are returned, which is exactly the entitlement filter
* #3121 requires.
* Parse a Copilot `/models` response into managed chat-model rows. Keeps every
* entitled CHAT model in the live response (capability-driven filtering) and
* drops only non-chat rows (embeddings / completion). Because only entitled
* models appear in the live response, this is exactly the entitlement filter
* #3121 needs — WITHOUT the old hardcoded id allowlist that silently dropped
* newly-entitled models (grok-4.6, mai-code-1.1-flash, gemini-3.6-flash, …).
*/
export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] {
const payload = asRecord(data);
@@ -89,7 +145,7 @@ export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] {
const item = asRecord(value);
const id = toNonEmptyString(item.id) || toNonEmptyString(item.model);
if (!id || seen.has(id)) continue;
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) continue;
if (!isRoutableChatModel(item)) continue;
seen.add(id);
const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id;
models.push({ id, name, owned_by: "github" });
@@ -120,7 +176,7 @@ function toFallbackResult(
.map((model) => {
const id = toNonEmptyString(model.id);
if (!id) return null;
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) return null;
if (!GITHUB_COPILOT_STATIC_FALLBACK_SET.has(id)) return null;
return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" };
})
.filter((model): model is GitHubCopilotModel => Boolean(model));

View File

@@ -39,7 +39,7 @@ function looksLikeAbsolutePath(tok: string): boolean {
return (SOURCE_EXT as readonly string[]).includes(ext);
}
function redactSensitiveErrorText(value: string): string {
export function redactSensitiveErrorText(value: string): string {
return value
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")

View File

@@ -15,6 +15,17 @@ const PASSTHROUGH_MAX = 499;
// quota wording the client needs.
const EXCLUDED_STATUSES = new Set([401, 403, 407]);
const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
// #10898-sec / secret-in-error hardening: some providers echo the offending
// request (including an Authorization header or api key) inside a 400/422/429
// validation body. Passthrough relays the body VERBATIM (the Claude Code
// capability-recovery contract needs the exact wording), so we cannot key-drop
// via sanitizeUpstreamDetails without breaking that contract. Instead, if the
// body actually carries a credential pattern, REFUSE passthrough and let the
// caller fall back to the sanitized buildErrorBody path. Bodies without a
// secret (the overwhelming majority, carrying capability/quota wording) still
// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts.
const CREDENTIAL_LEAK_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean {
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
@@ -22,6 +33,8 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
if (!upstreamBody || typeof upstreamBody !== "object") return false;
const text = JSON.stringify(upstreamBody);
if (INTERNAL_LEAK_RE.test(text)) return false;
// Refuse passthrough when the provider echoed a credential back to us.
if (CREDENTIAL_LEAK_RE.test(text)) return false;
return true;
}

View File

@@ -154,6 +154,15 @@ export function resolveNextBuildEnv(baseEnv = process.env, platform = process.pl
const env = {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
// Reliable build signal inherited by every spawned `next build` worker.
// Next.js workers sometimes drop NEXT_PHASE, so DB entry points key off
// OMNIROUTE_BUILDING=1 to stub out SQLite and never load the native
// better-sqlite3 addon (its Statement destructor SIGABRTs at worker
// teardown: node::RemoveEnvironmentCleanupHook). (#10060)
OMNIROUTE_BUILDING: "1",
// No telemetry, anywhere: disable Next.js's anonymous build-time telemetry
// on every build path (local, CI, Docker), not just the image build.
NEXT_TELEMETRY_DISABLED: baseEnv.NEXT_TELEMETRY_DISABLED || "1",
};
// Windows-only: `next build`'s static-generation glob scan and framework cache

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { homedir } from "os";
import { join } from "path";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isNextBuildPhase } from "@/lib/buildPhase";
import {
createProviderConnection,
getProviderConnections,
@@ -83,6 +84,11 @@ async function tryKiroCliSqlite(): Promise<{
let Database: any;
try {
// Never load the native better-sqlite3 addon during the Next.js build:
// its Statement destructor aborts with SIGABRT at build-worker teardown
// (node::RemoveEnvironmentCleanupHook). Kiro auto-import never runs during
// build, so returning "not found" here is safe. (#10060)
if (isNextBuildPhase()) throw new Error("Skip better-sqlite3 during build");
Database = (await import("better-sqlite3")).default;
} catch {
return { found: false, triedPaths: candidatePaths };

View File

@@ -1645,10 +1645,19 @@ export async function GET(
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
const psd = asRecord(connection.providerSpecificData);
// The /models endpoint requires the short-lived Copilot token (same as the
// chat executor), not the raw GitHub OAuth access token.
// Catalog discovery must present the RAW GitHub OAuth token (gho_...), not
// the exchanged short-lived Copilot token. The full entitled model catalog
// (incl. grok-4.x and mai-code) is only unlocked when the
// `copilot-integration-id: copilot-developer-cli` header rides on a raw
// GitHub Bearer; the exchanged copilot_internal/v2/token bearer is minted
// WITHOUT the developer-cli identity and unlocks only the narrower default
// set, so grok/mai silently vanish. api.githubcopilot.com accepts the raw
// token directly as Bearer. (Chat/inference in the executor may still use
// the exchanged token; only DISCOVERY needs the raw token.) This mirrors the
// Copilot CLI + Hermes "de-gate model discovery" fix. Exchanged token stays
// as a fallback for connections that only captured that.
const copilotToken =
toNonEmptyString(psd.copilotToken) || toNonEmptyString(accessToken) || null;
toNonEmptyString(accessToken) || toNonEmptyString(psd.copilotToken) || null;
const discovery = await fetchGitHubCopilotModels({
token: copilotToken,

26
src/lib/buildPhase.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Single source of truth for "are we running inside the Next.js production
* build?" — a leaf module with zero imports so any layer (db/core, the driver
* factory, lazy copilot loaders, API routes) can depend on it without creating
* an import cycle.
*
* Three signals, OR'd, because no single one is reliable across every build
* worker:
* - NEXT_PHASE === "phase-production-build": set by Next.js on the main build
* process, but Next.js build WORKERS sometimes drop it from process.env.
* - OMNIROUTE_BUILDING === "1": set by scripts/build/build-next-isolated.mjs
* and inherited by every spawned build worker, so it survives where
* NEXT_PHASE does not (#10060).
* - npm_lifecycle_event === "build": set by npm when the process was launched
* via `npm run build`, a backstop for direct invocations.
*
* Evaluated per-call (not memoized) so tests can toggle the env vars and code
* paths that legitimately mutate them at startup are respected.
*/
export function isNextBuildPhase(): boolean {
return (
process.env.NEXT_PHASE === "phase-production-build" ||
process.env.OMNIROUTE_BUILDING === "1" ||
process.env.npm_lifecycle_event === "build"
);
}

View File

@@ -11,6 +11,7 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { isNextBuildPhase } from "../buildPhase";
// ---------------------------------------------------------------------------
// Types
@@ -92,6 +93,12 @@ function queryDb(query: string, params: unknown[] = []): CodeGraphQueryResult {
// Use better-sqlite3 if available
try {
// Never load the native better-sqlite3 addon during the Next.js build:
// its Statement destructor aborts with SIGABRT at build-worker teardown
// (node::RemoveEnvironmentCleanupHook). This path is not exercised during
// build, so failing closed to "not available" is safe. (#10060)
if (isNextBuildPhase()) throw new Error("Skip better-sqlite3 during build");
const Database = require("better-sqlite3");
_db = new Database(dbPath, { readonly: true });
} catch {

View File

@@ -1,4 +1,5 @@
import { runtimeRequire as _require } from "./runtimeRequire";
import { isNextBuildPhase } from "../../buildPhase";
import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createBetterSqliteAdapter } from "./betterSqliteAdapter";
@@ -234,8 +235,17 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?:
}
}
// 2. better-sqlite3: preferred native driver on Node.js
if (!process.versions.bun && mayLoadBetterSqlite()) {
// 2. better-sqlite3: preferred native driver on Node.js. Skipped on Bun and
// during the Next.js production build. Build workers sometimes lose
// NEXT_PHASE from process.env, so OMNIROUTE_BUILDING=1 (set by
// build-next-isolated.mjs and inherited by the build workers) is the primary
// build signal. Deliberately does NOT check isMainThread: at runtime many
// worker threads (pino thread-stream, compression workers) legitimately use
// better-sqlite3, and skipping it there would silently degrade to
// node:sqlite / sql.js in production. During the build the native addon
// cannot load: the Statement destructor aborts with SIGABRT on worker
// teardown (node::RemoveEnvironmentCleanupHook). (#10060)
if (!process.versions.bun && !isNextBuildPhase() && mayLoadBetterSqlite()) {
try {
const BetterSqlite = load("better-sqlite3") as {
new (p: string, o?: object): import("better-sqlite3").Database;

View File

@@ -0,0 +1,31 @@
// Build-time stub for better-sqlite3 (#10060).
//
// Aliased in for the Next.js production build (turbopack + webpack) so the
// bundler never pulls the real native addon into a build worker. The native
// Statement destructor aborts with SIGABRT when a build worker thread exits
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
// leave the build with no standalone output. At runtime the real package is
// used (it is listed in serverExternalPackages, so it is require()'d natively,
// not bundled); this stub only stands in during the build, where the DB is
// never actually queried.
class Database {
constructor() {}
prepare() {
return {
run: () => ({ changes: 0, lastInsertRowid: 0 }),
get: () => undefined,
all: () => [],
};
}
exec() {}
pragma() {}
transaction(fn) {
return fn;
}
backup() {
return Promise.resolve({});
}
close() {}
}
module.exports = Database;

View File

@@ -4,7 +4,7 @@
* All domain modules import `getDbInstance` and helpers from here.
*/
import type { SqliteAdapter } from "./adapters/types";
import type { SqliteAdapter, PreparedStatement } from "./adapters/types";
import {
tryOpenSync,
getSqlJsAdapter,
@@ -16,6 +16,7 @@ import path from "path";
import { retryProbeIfTransient } from "./probeUtils";
import fs from "fs";
import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths";
import { isNextBuildPhase } from "../buildPhase";
import { runMigrations } from "./migrationRunner";
import { runDbHealthCheck } from "./healthCheck";
import { resetAllDbModuleState } from "./stateReset";
@@ -84,7 +85,18 @@ type CriticalTableSpec = {
export const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
// Next.js build workers sometimes drop NEXT_PHASE from their env, so
// OMNIROUTE_BUILDING=1 (set by build-next-isolated.mjs and inherited by every
// spawned build worker) is the reliable build signal. During build the native
// better-sqlite3 addon must never load: its Statement destructor aborts with
// SIGABRT when the worker thread exits (assertion in
// node::RemoveEnvironmentCleanupHook, env == nullptr). (#10060)
//
// Delegates to the shared leaf helper (src/lib/buildPhase.ts) so every build
// signal is defined in exactly one place. Kept as a module const (evaluated at
// import time) to preserve the existing eager-boolean semantics of the many
// `if (isBuildPhase || isCloud)` call sites across the db layer.
export const isBuildPhase = isNextBuildPhase();
// ──────────────── Paths ────────────────
@@ -1022,7 +1034,34 @@ export function getDbInstance(): SqliteDatabase {
if (isCloud || isBuildPhase) {
if (isBuildPhase) {
console.log("[DB] Build phase detected — using in-memory SQLite (read-only)");
console.log("[DB] Build phase detected — using no-op SQLite stub (never queried)");
// A no-op stub during build avoids loading the better-sqlite3 native
// bindings entirely. The native Statement destructor crashes with SIGABRT
// when the Next.js build worker thread exits (assertion in
// node::RemoveEnvironmentCleanupHook, env == nullptr). The DB is never
// actually queried during build — it only exists so module-eval that
// touches getDbInstance() at build time does not throw. (#10060)
const noopStatement: PreparedStatement = {
run: () => ({ changes: 0, lastInsertRowid: 0 }),
get: () => undefined,
all: () => [],
};
const stubDb: SqliteDatabase = {
driver: "sql.js",
open: true,
name: ":memory:",
prepare: () => noopStatement,
exec: () => {},
pragma: () => undefined,
transaction: <T>(fn: (...args: unknown[]) => T) => fn,
immediate: (fn: () => void) => fn(),
backup: async () => {},
checkpoint: () => {},
close: () => {},
raw: null,
};
setDb(stubDb);
return stubDb;
}
const memoryDb = openSqliteDatabase(":memory:");
memoryDb.pragma("journal_mode = WAL");

View File

@@ -1,7 +1,6 @@
/** db/models/compat.ts — model-compat overrides (normalizeToolCallId, per-protocol flags, upstream headers). */
import { getDbInstance } from "../core";
import { resolveProviderAlias } from "@omniroute/open-sse/services/model.ts";
import {
MODEL_COMPAT_PROTOCOL_KEYS,
type ModelCompatProtocolKey,
@@ -121,11 +120,10 @@ export type ModelCompatOverride = {
};
export function readCompatList(providerId: string): ModelCompatOverride[] {
const canonicalId = resolveProviderAlias(providerId) || providerId;
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(MODEL_COMPAT_NAMESPACE, canonicalId);
.get(MODEL_COMPAT_NAMESPACE, providerId);
const value = getKeyValue(row).value;
if (!value) return [];
try {
@@ -145,17 +143,16 @@ export function readCompatList(providerId: string): ModelCompatOverride[] {
}
export function writeCompatList(providerId: string, list: ModelCompatOverride[]) {
const canonicalId = resolveProviderAlias(providerId) || providerId;
const db = getDbInstance();
if (list.length === 0) {
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
MODEL_COMPAT_NAMESPACE,
canonicalId
providerId
);
} else {
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
MODEL_COMPAT_NAMESPACE,
canonicalId,
providerId,
JSON.stringify(list)
);
}

View File

@@ -16,6 +16,23 @@ const SENSITIVE_KEYS = new Set([
"password",
"secret",
"token",
// secret-leak hardening: session cookies + browser-storage credentials that
// some web-impersonation providers (Meta AI ecto_1_sess, chatgpt-web
// storageState / runtimeKey) can surface into a request/response BODY field
// rather than a header. Header-borne values are already masked by
// maskSensitiveHeaders; this covers the body path into the on-disk call-log
// artifact. Scoped to the actual credential field names only — the generic
// word "capability" was intentionally NOT included: it is a common non-secret
// field (model catalogs' `capabilities`, degradation/provider-discovery
// `capability` strings, MCP tool schemas) and matching it here would broadly
// redact useful diagnostics from call-log artifacts. The real Meta AI secret
// is the ecto_1_sess cookie / ecto1: WS token, already covered by
// cookie/authorization/storageState above.
"cookie",
"Cookie",
"storageState",
"storage-state",
"runtimeKey",
]);
type JsonRecord = Record<string, unknown>;

View File

@@ -5,7 +5,8 @@ import { resolveDataDir } from "../dataPaths";
import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";
const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
const isBuildPhase =
process.env.NEXT_PHASE === "phase-production-build" || process.env.OMNIROUTE_BUILDING === "1";
const DATA_DIR = resolveDataDir({ isCloud });
export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");

View File

@@ -10,6 +10,7 @@ import {
import { getCachedSettings } from "@/lib/localDb";
import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog";
import { getModelCompatOverrides } from "@/lib/db/models/compat";
import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings";
import {
parseModel,
getModelInfoCore,
@@ -329,7 +330,17 @@ async function lookupModelMeta(
const [customModels, liveCatalog, compatOverrides] = await Promise.all([
getCustomModels(providerId),
getActiveSyncedCatalog(providerId),
Promise.resolve(getModelCompatOverrides(providerId)),
// #10898 / #7620: model-compat overrides (apiFormat/targetFormat/
// supportsVision, isHidden, ...) are stored keyed on the id the operator
// wrote them under. For a no-auth alias the model prefix resolves to the
// APIKEY gateway id (e.g. "opencode/x" -> providerId "opencode-zen") but
// the override was written on the sibling "opencode" row. Merge overrides
// across the provider AND its no-auth sibling ids (requested id first)
// instead of canonicalizing the low-level compat key, which would break
// paths that legitimately key on the raw id (e.g. getHiddenModelsByProvider).
Promise.resolve(
getNoAuthHydrationProviderIds(providerId).flatMap((id) => getModelCompatOverrides(id))
),
]);
const syncedModels = liveCatalog.models;

View File

@@ -2468,39 +2468,42 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
},
"nonStream": {
"Accept": "application/json",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user"
}
},
"url": {
@@ -2539,42 +2542,45 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-request-id": "<UUID>",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
},
"nonStream": {
"Accept": "application/json",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-request-id": "<UUID>",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"X-Initiator": "user",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.54.0",
"editor-version": "vscode/1.126.0",
"openai-intent": "conversation-panel",
"user-agent": "GitHubCopilotChat/0.54.0",
"x-github-api-version": "2026-06-01",
"x-request-id": "<UUID>",
"x-vscode-user-agent-library-version": "electron-fetch"
"copilot-harness-id": "copilot-sdk",
"copilot-integration-id": "copilot-developer-cli",
"editor-version": "copilot/1.0.81-6",
"openai-intent": "conversation-agent",
"user-agent": "copilot/1.0.81-6",
"x-client-machine-id": "<UUID>",
"x-github-api-version": "2026-08-01",
"x-interaction-type": "conversation-user",
"x-request-id": "<UUID>"
}
},
"url": {

View File

@@ -0,0 +1,69 @@
/**
* #10060 — during the Next.js production build the native better-sqlite3 addon
* must never load. Its Statement destructor aborts with SIGABRT when a build
* worker thread exits (assertion in node::RemoveEnvironmentCleanupHook), which
* can leave the build with no standalone output.
*
* The reliable build signal is OMNIROUTE_BUILDING=1 (set by
* build-next-isolated.mjs and inherited by every spawned build worker), because
* Next.js workers sometimes drop NEXT_PHASE. These tests pin the two contracts
* that keep the addon out of the build:
*
* 1. build-next-isolated.mjs exports OMNIROUTE_BUILDING=1 into the build env.
* 2. getDbInstance() returns a no-op stub (never the native driver) whenever
* the build signal is set, and that stub satisfies the SqliteAdapter shape.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { resolveNextBuildEnv } from "../../../scripts/build/build-next-isolated.mjs";
describe("#10060 build env carries OMNIROUTE_BUILDING", () => {
it("resolveNextBuildEnv sets OMNIROUTE_BUILDING=1", () => {
const env = resolveNextBuildEnv({}, "linux");
assert.equal(env.OMNIROUTE_BUILDING, "1");
});
it("preserves provided env keys and does not clobber the build-worker flag", () => {
const env = resolveNextBuildEnv({ NEXT_PRIVATE_BUILD_WORKER: "1" }, "linux");
assert.equal(env.NEXT_PRIVATE_BUILD_WORKER, "1");
assert.equal(env.OMNIROUTE_BUILDING, "1");
});
});
describe("#10060 getDbInstance stubs SQLite during build", () => {
const savedBuilding = process.env.OMNIROUTE_BUILDING;
const savedPhase = process.env.NEXT_PHASE;
beforeEach(() => {
delete process.env.NEXT_PHASE;
process.env.OMNIROUTE_BUILDING = "1";
});
afterEach(() => {
if (savedBuilding === undefined) delete process.env.OMNIROUTE_BUILDING;
else process.env.OMNIROUTE_BUILDING = savedBuilding;
if (savedPhase === undefined) delete process.env.NEXT_PHASE;
else process.env.NEXT_PHASE = savedPhase;
});
it("returns a no-op stub (never the native better-sqlite3 driver) under the build signal", async () => {
// Import fresh so isBuildPhase is evaluated with OMNIROUTE_BUILDING set.
const mod = await import(`../../../src/lib/db/core.ts?build-stub=${Date.now()}`);
const db = mod.getDbInstance();
// Must NOT be the native addon — that is the whole point of the fix.
assert.notEqual(db.driver, "better-sqlite3");
assert.equal(db.open, true);
// The stub satisfies the SqliteAdapter surface the build's module-eval touches.
const stmt = db.prepare("SELECT 1 AS x");
assert.equal(stmt.get(), undefined);
assert.deepEqual(stmt.all(), []);
assert.deepEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 });
assert.doesNotThrow(() => db.exec("CREATE TABLE t (a)"));
assert.doesNotThrow(() => db.pragma("journal_mode = WAL"));
assert.doesNotThrow(() => db.close());
});
});

View File

@@ -780,6 +780,13 @@ test("handleChatCore preserves client cache markers for Claude Code requests to
type: "ephemeral",
ttl: "5m",
});
// The system block above carries an explicit 5m cache_control, which trips the
// 5m breakpoint in normalizeCacheControlTtl (#10684: "defaults missing ttl to
// 5m after a 5m breakpoint", sections are processed tools -> system ->
// messages). So this user message's client marker, sent with no ttl, defaults
// to 5m rather than 1h. #10684 updated claude-code-parity.test.ts /
// chatcore-translation-paths.test.ts for this but missed this assertion,
// leaving it a base-red on release/v3.8.50.
assert.deepEqual(calls[0].body.messages[0].content[0].cache_control, {
type: "ephemeral",
ttl: "5m",

View File

@@ -0,0 +1,71 @@
// Claude models must ALWAYS use the Anthropic-native /v1/messages shim on both
// github.com Copilot and GitHub Enterprise (GHE) Copilot — never /chat/completions
// or /responses. The base github executor and the GHE override both match on the
// model NAME (not only the registry's per-model targetFormat tag), so a Claude
// model that is missing its targetFormat tag, or a custom/newer Claude id not yet
// in the static registry, still gets the native shim. Mirrors the Hermes copilot
// routing (`if "claude" in model: return CAPI_MESSAGES_URL`).
import test from "node:test";
import assert from "node:assert/strict";
const { GithubExecutor } = await import("../../open-sse/executors/github.ts");
const { GheCopilotExecutor } = await import("../../open-sse/executors/ghe-copilot.ts");
test("github.com: an untagged Claude id still routes to /v1/messages", () => {
const executor = new GithubExecutor();
// A Claude id NOT in the static registry (so getModelTargetFormat is null).
const url = executor.buildUrl("claude-opus-9.9-experimental", true);
assert.equal(
url,
"https://api.githubcopilot.com/v1/messages",
"any claude-* id must hit the native shim even without a registry targetFormat tag"
);
});
test("github.com: a custom 'anthropic/claude' style id routes to /v1/messages", () => {
const executor = new GithubExecutor();
const url = executor.buildUrl("claude-sonnet-5-preview", true);
assert.match(url, /\/v1\/messages$/);
});
test("github.com: non-claude ids are unaffected (gpt -> /responses, plain -> /chat/completions)", () => {
const executor = new GithubExecutor();
assert.match(executor.buildUrl("gpt-5.4", true), /\/responses$/);
assert.match(executor.buildUrl("gpt-4o-mini", true), /\/chat\/completions$/);
});
test("GHE: Claude models route to the dynamic per-connection /v1/messages host", () => {
const executor = new GheCopilotExecutor();
const creds = {
accessToken: "tok",
providerSpecificData: { copilotApiUrl: "https://copilot.enterprise.example/api/v1" },
};
// GHE strips the ghe-copilot/ prefix; the Claude match must fire on the bare id.
const url = executor.buildUrl("ghe-copilot/claude-opus-4.8", true, 0, creds);
assert.equal(
url,
"https://copilot.enterprise.example/api/v1/v1/messages",
"GHE Claude must hit the per-connection host's /v1/messages, not /chat/completions"
);
});
test("GHE: an untagged Claude id still routes to /v1/messages", () => {
const executor = new GheCopilotExecutor();
const creds = {
accessToken: "tok",
providerSpecificData: { copilotApiUrl: "https://ghe.example/copilot" },
};
const url = executor.buildUrl("ghe-copilot/claude-future-x", true, 0, creds);
assert.match(url, /\/v1\/messages$/);
});
test("GHE: non-claude ids still route to /chat/completions on the dynamic host", () => {
const executor = new GheCopilotExecutor();
const creds = {
accessToken: "tok",
providerSpecificData: { copilotApiUrl: "https://ghe.example/copilot" },
};
const url = executor.buildUrl("ghe-copilot/gpt-4o", true, 0, creds);
assert.match(url, /\/chat\/completions$/);
});

View File

@@ -60,7 +60,7 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
}
});
it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => {
it("still avoids /responses if a Claude/Gemini model is wrongly tagged openai-responses", () => {
const exec = new GithubExecutor();
const claude = getGithubModel("claude-sonnet-4.6");
const gemini = getGithubModel("gemini-3.1-pro-preview");
@@ -68,11 +68,13 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
const originalClaude = claude.targetFormat;
const originalGemini = gemini.targetFormat;
try {
// Simulate a future misconfiguration. The guard must still hold.
// Simulate a future misconfiguration. The guard must still hold: Claude
// ALWAYS resolves to the native /v1/messages shim (name match beats the
// bad tag), Gemini stays on /chat/completions. Neither hits /responses.
claude.targetFormat = "openai-responses";
gemini.targetFormat = "openai-responses";
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), MESSAGES_URL);
assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL);
} finally {
claude.targetFormat = originalClaude;
@@ -101,10 +103,9 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
const original = claude.targetFormat;
try {
claude.targetFormat = "openai-responses";
// Look up by the same id (registry is case-sensitive on lookup) but with a
// mixed-case path through the guard. We rebuild with the registered id;
// the guard normalizes before substring check, so it must still detect.
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
// Even wrongly tagged, a claude-* id resolves to the native shim (the
// name match is case-insensitive), never /responses.
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), MESSAGES_URL);
} finally {
claude.targetFormat = original;
}

View File

@@ -65,10 +65,12 @@ test("GithubExecutor.buildUrl routes response-format models to /responses", () =
}
});
test("GithubExecutor.buildUrl keeps GitHub Claude Opus 4.6 on /chat/completions", () => {
test("GithubExecutor.buildUrl routes GitHub Claude Opus 4.6 to the native /v1/messages shim", () => {
const executor = new GithubExecutor();
const url = executor.buildUrl("claude-opus-4.6", true);
assert.equal(url, "https://api.githubcopilot.com/chat/completions");
// Claude ALWAYS uses the Anthropic-native shim (prompt-cache token counts +
// lossless tool blocks), never /chat/completions.
assert.equal(url, "https://api.githubcopilot.com/v1/messages");
});
test("GithubExecutor.buildUrl routes unlisted Codex models to /responses (9router#102)", () => {
@@ -276,13 +278,49 @@ test("GithubExecutor.buildHeaders prefers Copilot token and sets GitHub-specific
assert.equal(headers.Authorization, "Bearer copilot-token");
assert.equal(headers.Accept, "text/event-stream");
assert.equal(headers["editor-version"], "vscode/1.126.0");
assert.equal(headers["editor-plugin-version"], "copilot-chat/0.54.0");
assert.equal(headers["user-agent"], "GitHubCopilotChat/0.54.0");
assert.equal(headers["x-github-api-version"], "2026-06-01");
assert.equal(headers["openai-intent"], "conversation-panel");
// Copilot CLI wire identity (matches the `copilot` npm package, not VS Code).
assert.equal(headers["editor-version"], "copilot/1.0.81-6");
assert.equal(headers["user-agent"], "copilot/1.0.81-6");
assert.equal(headers["x-github-api-version"], "2026-08-01");
assert.equal(headers["openai-intent"], "conversation-agent");
assert.equal(headers["copilot-integration-id"], "copilot-developer-cli");
assert.equal(headers["x-interaction-type"], "conversation-user");
assert.equal(headers["copilot-harness-id"], "copilot-sdk");
assert.equal(headers["X-Initiator"], "user");
assert.ok(headers["x-request-id"]);
// CLI 1.0.81-6 correlation headers.
assert.ok(headers["x-client-machine-id"], "stable per-install machine id present");
assert.ok(headers["x-interaction-id"], "per-call interaction id present");
assert.ok(headers["x-client-session-id"], "per-conversation session id present");
assert.ok(headers["x-agent-task-id"], "per-turn task id present");
assert.equal(headers["x-github-repository-nwo"], "__no_repository__");
assert.equal(headers["x-github-repository-host"], "__no_repository__");
assert.equal(headers["x-stainless-helper-method"], "stream");
// The CLI does NOT send editor-plugin-version / the vscode library header on
// the inference path — those are VS Code Copilot Chat extension only.
assert.equal(headers["editor-plugin-version"], undefined);
assert.equal(headers["x-vscode-user-agent-library-version"], undefined);
});
test("GithubExecutor.buildHeaders omits x-stainless-helper-method for non-stream and honors client-pinned ids", () => {
const executor = new GithubExecutor();
const nonStream = executor.buildHeaders({ accessToken: "gh" }, false);
assert.equal(
nonStream["x-stainless-helper-method"],
undefined,
"stainless stream signature only on streamed turns"
);
const pinned = executor.buildHeaders({ accessToken: "gh" }, true, {
"x-client-session-id": "sess-123",
"x-agent-task-id": "task-456",
"x-github-repository-nwo": "octo/repo",
"x-github-repository-host": "github.com",
});
assert.equal(pinned["x-client-session-id"], "sess-123", "client-pinned session id honored");
assert.equal(pinned["x-agent-task-id"], "task-456", "client-pinned task id honored");
assert.equal(pinned["x-github-repository-nwo"], "octo/repo", "client repo nwo forwarded");
assert.equal(pinned["x-github-repository-host"], "github.com", "client repo host forwarded");
});
test("GithubExecutor.buildHeaders forwards valid client x-initiator and falls back for invalid values", () => {

View File

@@ -71,7 +71,7 @@ test("buildUrl uses responses endpoint for gpt-5.4-mini and gpt-5.6-sol", () =>
);
});
test("buildUrl uses chat/completions endpoint for claude and gemini models", () => {
test("buildUrl routes Claude to the native /v1/messages shim (not chat/completions)", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
@@ -80,10 +80,24 @@ test("buildUrl uses chat/completions endpoint for claude and gemini models", ()
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
// Claude must ALWAYS use the Anthropic-native shim (prompt-cache token counts +
// lossless tool_use/tool_result/thinking blocks), same as github.com Copilot.
assert.strictEqual(
executor.buildUrl("claude-opus-5", true, 0, credentials),
"https://ghe.company.com/chat/completions"
"https://ghe.company.com/v1/messages"
);
});
test("buildUrl uses chat/completions endpoint for gemini models", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
// Gemini has no native shim on Copilot — it stays on /chat/completions.
assert.strictEqual(
executor.buildUrl("gemini-3.5-flash", true, 0, credentials),
"https://ghe.company.com/chat/completions"

View File

@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
fetchGitHubCopilotModels,
GITHUB_COPILOT_MODELS_URL,
} from "../../open-sse/services/githubCopilotModels.ts";
// Regression guard for the Copilot catalog-discovery token fix.
//
// The full entitled Copilot model catalog (incl. grok-4.x and mai-code) is only
// unlocked when `copilot-integration-id: copilot-developer-cli` rides on a RAW
// GitHub Bearer token. The exchanged copilot_internal/v2/token bearer is minted
// without the developer-cli identity and unlocks only the narrower default set,
// silently dropping grok/mai. So discovery in
// src/app/api/providers/[id]/models/route.ts now prefers the raw accessToken over
// psd.copilotToken. These tests pin the two halves of the contract:
// (a) fetchGitHubCopilotModels sends whatever token it is given as
// `Authorization: Bearer *** on api.githubcopilot.com/models, and
// (b) a /responses-only entitled model (grok/mai shape) is preserved, not
// filtered out, when the live catalog returns it.
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}
test("fetchGitHubCopilotModels sends the given token as Authorization: Bearer", async () => {
let seenUrl = "";
let seenAuth: string | null = null;
let seenIntegrationId: string | null = null;
const result = await fetchGitHubCopilotModels({
token: "gho_raw_github_token",
fetchImpl: (async (url: string, init?: RequestInit) => {
seenUrl = String(url);
const headers = new Headers(init?.headers as HeadersInit);
seenAuth = headers.get("authorization");
seenIntegrationId = headers.get("copilot-integration-id");
return jsonResponse({
data: [
{ id: "gpt-5.6", capabilities: { type: "chat" } },
// grok/mai are /responses-only; they must survive discovery.
{ id: "grok-4.6", capabilities: { type: "chat" }, supported_endpoints: ["/responses"] },
{ id: "mai-code-1.1-flash", supported_endpoints: ["/responses"] },
],
});
}) as typeof fetch,
});
assert.equal(seenUrl, GITHUB_COPILOT_MODELS_URL);
// The raw token is presented verbatim — the unlock lever.
assert.equal(seenAuth, "Bearer gho_raw_github_token");
// The developer-cli integration id is what unlocks the full catalog.
assert.equal(seenIntegrationId, "copilot-developer-cli");
assert.equal(result.source, "api");
});
test("fetchGitHubCopilotModels keeps /responses-only entitled models (grok/mai)", async () => {
const result = await fetchGitHubCopilotModels({
token: "gho_raw_github_token",
fetchImpl: (async () =>
jsonResponse({
data: [
{ id: "grok-4.6", capabilities: { type: "chat" }, supported_endpoints: ["/responses"] },
{ id: "mai-code-1.1-flash", supported_endpoints: ["/responses"] },
],
})) as typeof fetch,
});
assert.equal(result.source, "api");
const ids = new Set(result.models.map((m) => m.id));
assert.ok(ids.has("grok-4.6"), "grok-4.6 must survive discovery");
assert.ok(ids.has("mai-code-1.1-flash"), "mai-code must survive discovery");
});

View File

@@ -20,13 +20,15 @@ import assert from "node:assert/strict";
const {
GITHUB_COPILOT_MODELS_URL,
GITHUB_COPILOT_MODEL_ALLOWLIST,
GITHUB_COPILOT_STATIC_FALLBACK_MODELS,
parseGitHubCopilotModels,
fetchGitHubCopilotModels,
} = await import("../../open-sse/services/githubCopilotModels.ts");
// A representative slice of a real Copilot /models response. The upstream list
// includes selectable chat models plus utility/legacy models; OmniRoute imports
// only the curated allowlist.
// includes selectable chat models plus utility/legacy models; discovery now
// keeps every entitled CHAT model (capability-driven) and drops only non-chat
// rows (embeddings / completion).
const MOCK_COPILOT_MODELS_RESPONSE = {
data: [
{
@@ -43,29 +45,47 @@ const MOCK_COPILOT_MODELS_RESPONSE = {
capabilities: { type: "chat" },
},
{
// Embeddings model — present upstream but intentionally not in the curated chat list.
// Newly-entitled model NOT in any hardcoded list — must still be kept now
// that discovery is capability-driven (this is the whole point of the fix).
id: "grok-4.6",
name: "Grok 4.6",
model_picker_enabled: true,
capabilities: { type: "chat" },
supported_endpoints: ["/responses"],
},
{
// Embeddings model — present upstream but not a routable chat model.
id: "text-embedding-3-small",
name: "Embedding V3 small",
capabilities: { type: "embeddings" },
},
{
// Raw completion utility — also excluded.
id: "gpt-41-copilot",
name: "Copilot Completion",
capabilities: { type: "completion" },
},
],
};
test("#3120 parseGitHubCopilotModels maps data[].id into managed models", () => {
test("#3120 parseGitHubCopilotModels keeps every entitled CHAT model (capability-driven)", () => {
const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE);
const ids = models.map((m) => m.id);
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]);
// grok-4.6 is kept even though it is in no hardcoded allowlist — it's an
// entitled chat model in the live response.
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5", "grok-4.6"]);
const gpt = models.find((m) => m.id === "gpt-5.4");
assert.ok(gpt, "gpt-5.4 entry present");
assert.equal(gpt.name, "GPT-5.4");
assert.equal(gpt.owned_by, "github");
assert.ok(!ids.includes("text-embedding-3-small"), "non-allowlisted utility models are skipped");
assert.ok(!ids.includes("text-embedding-3-small"), "embeddings models are skipped");
assert.ok(!ids.includes("gpt-41-copilot"), "completion utility models are skipped");
});
test("#3121 a model NOT in the live response is not advertised (entitlement filtering)", () => {
const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE);
const ids = models.map((m) => m.id);
// gemini-3.1-pro-preview is in the OLD static catalog but NOT entitled here.
// gemini-3.1-pro-preview is not entitled here (absent from the live response).
assert.ok(
!ids.includes("gemini-3.1-pro-preview"),
"non-entitled gemini preview must NOT be advertised"
@@ -99,7 +119,7 @@ test("#3120 fetchGitHubCopilotModels does a live fetch and returns parsed models
assert.ok(capturedHeaders["copilot-integration-id"], "must send Copilot integration header");
assert.equal(result.source, "api");
const ids = result.models.map((m) => m.id);
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]);
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5", "grok-4.6"]);
assert.ok(!ids.includes("gemini-3.1-pro-preview"));
});
@@ -125,38 +145,30 @@ test("#3120/#3121 fetch falls back to static catalog when the live fetch fails",
);
});
test("curated Copilot allowlist contains the final approved model ids only", () => {
assert.deepEqual(
[...GITHUB_COPILOT_MODEL_ALLOWLIST],
[
"claude-fable-5",
"claude-opus-5",
"claude-opus-4.8-fast",
"claude-opus-4.8",
"claude-opus-4.7",
"claude-sonnet-4.6",
"claude-opus-4.5",
"claude-sonnet-5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3.1-pro-preview",
"gemini-3.7-flash",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5-mini",
"gpt-4o-2024-11-20",
"gpt-4o-mini",
"gpt-4-0125-preview",
"kimi-k2.7-code",
"mai-code-1-flash",
"oswe-vscode-prime",
]
);
test("static fallback catalog is the alias of the allowlist and covers the approved chat ids", () => {
// Back-compat: the old name still points at the fallback catalog.
assert.equal(GITHUB_COPILOT_MODEL_ALLOWLIST, GITHUB_COPILOT_STATIC_FALLBACK_MODELS);
const set = new Set<string>(GITHUB_COPILOT_STATIC_FALLBACK_MODELS);
// The fallback must include the newly-entitled families so an offline import
// (which can only draw from this static list) still surfaces them.
for (const id of [
"claude-fable-5",
"claude-opus-5",
"claude-opus-4.8-fast",
"claude-opus-4.6",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gpt-5.4-nano",
"grok-4.6",
"grok-4.5",
"mai-code-1.1-flash",
"mai-code-1-flash-picker",
]) {
assert.ok(set.has(id), `static fallback must include ${id}`);
}
// No embeddings / completion utilities belong in the chat fallback catalog.
assert.ok(!set.has("text-embedding-3-small"));
assert.ok(!set.has("gpt-41-copilot"));
});
test("newly approved Copilot models survive live and fallback discovery", async () => {

View File

@@ -0,0 +1,98 @@
/**
* Regression: #7620 hidden-model persistence must survive the #10898 compat
* canonicalization (fixed in this PR by keying the low-level compat store on the
* RAW providerId and merging overrides across no-auth siblings at resolution).
*
* The bug: #10898 canonicalized the compat key via resolveProviderAlias inside
* readCompatList/writeCompatList. setModelIsHidden / mergeModelCompatOverride
* writes the isHidden override under the raw no-auth id "opencode", but #10898
* relocated the write to the canonical APIKEY gateway id "opencode-zen". The
* hidden-model reader (getHiddenModelsByProvider) still keyed on "opencode", so
* it read an empty row and a hidden no-auth model reappeared in the auto-combo
* pool.
*
* The fix has two halves, both pinned here:
* (1) the low-level compat store keys on the RAW providerId again, so an
* override written under "opencode" lands on the "opencode" key and is
* NOT visible under the sibling "opencode-zen" key; and
* (2) resolution (getNoAuthHydrationProviderIds) merges overrides across the
* provider AND its no-auth siblings (requested id first), so a lookup that
* resolves the model prefix to "opencode-zen" still finds the override the
* operator wrote under "opencode".
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7620-sibling-compat-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "compat-sibling-test-secret";
const { mergeModelCompatOverride, getModelCompatOverrides } = await import(
"../../src/lib/db/models/compat.ts"
);
const { getNoAuthHydrationProviderIds } = await import(
"../../src/sse/services/noAuthProviderSiblings.ts"
);
const { getModelInfo } = await import("../../src/sse/services/model.ts");
test("#7620: isHidden override written under raw 'opencode' stays on the raw key, not the 'opencode-zen' sibling", () => {
mergeModelCompatOverride("opencode", "grok-code-fast-1", { isHidden: true });
const rawOverrides = getModelCompatOverrides("opencode");
const rawEntry = rawOverrides.find((m) => m.id === "grok-code-fast-1");
assert.ok(rawEntry, "override must be stored on the raw 'opencode' key");
assert.equal(rawEntry.isHidden, true);
// #10898 regression guard: the write must NOT have been canonicalized onto the
// APIKEY gateway id. If it had, the raw-keyed hidden reader would miss it.
const siblingOverrides = getModelCompatOverrides("opencode-zen");
const leaked = siblingOverrides.find((m) => m.id === "grok-code-fast-1");
assert.equal(
leaked,
undefined,
"override must NOT leak onto the 'opencode-zen' key (that was the #10898 regression)"
);
});
test("getNoAuthHydrationProviderIds merges the no-auth sibling so 'opencode-zen' resolution reaches 'opencode' overrides", () => {
// Sibling map contract: opencode-zen (and opencode-go) hydrate from opencode.
assert.deepEqual(getNoAuthHydrationProviderIds("opencode-zen"), ["opencode-zen", "opencode"]);
assert.deepEqual(getNoAuthHydrationProviderIds("opencode-go"), ["opencode-go", "opencode"]);
// A provider with no siblings resolves to just itself (requested id first).
assert.deepEqual(getNoAuthHydrationProviderIds("opencode"), ["opencode"]);
// End-to-end: an override written under "opencode" is found when the merged
// sibling set for the resolved gateway id "opencode-zen" is walked.
mergeModelCompatOverride("opencode", "claude-sonnet-5", {
apiFormat: "responses",
targetFormat: "claude",
isHidden: true,
});
const merged = getNoAuthHydrationProviderIds("opencode-zen").flatMap((id) =>
getModelCompatOverrides(id)
);
const resolved = merged.find((m) => m.id === "claude-sonnet-5");
assert.ok(resolved, "sibling-merged overrides must include the 'opencode' row");
assert.equal(resolved.isHidden, true);
assert.equal(resolved.apiFormat, "responses");
assert.equal(resolved.targetFormat, "claude");
});
test("#10898 stays fixed: getModelInfo('opencode/<model>') resolves to opencode-zen and reads the sibling override", async () => {
mergeModelCompatOverride("opencode", "claude-opus-5", {
apiFormat: "responses",
targetFormat: "claude",
supportsVision: true,
});
const info = await getModelInfo("opencode/claude-opus-5");
assert.equal(info.provider, "opencode-zen");
assert.equal(info.apiFormat, "responses");
assert.equal(info.targetFormat, "claude");
assert.equal(info.supportsVision, true);
});

View File

@@ -4,13 +4,18 @@ import assert from "node:assert/strict";
import {
GITHUB_COPILOT_API_VERSION,
GITHUB_COPILOT_CHAT_PLUGIN_VERSION,
GITHUB_COPILOT_CLI_USER_AGENT,
GITHUB_COPILOT_CHAT_USER_AGENT,
GITHUB_COPILOT_EDITOR_VERSION,
GITHUB_COPILOT_INTEGRATION_ID,
GITHUB_COPILOT_INTERACTION_TYPE,
GITHUB_COPILOT_HARNESS_ID,
GITHUB_COPILOT_REFRESH_PLUGIN_VERSION,
GITHUB_COPILOT_REFRESH_USER_AGENT,
KIRO_AMZ_USER_AGENT,
KIRO_SDK_USER_AGENT,
QWEN_CLI_VERSION,
getGitHubCopilotMachineId,
getQwenCliUserAgent,
getGitHubCopilotChatHeaders,
getGitHubCopilotInternalUserHeaders,
@@ -21,12 +26,27 @@ import {
test("provider header profiles expose current GitHub chat and internal headers", () => {
const chatHeaders = getGitHubCopilotChatHeaders("text/event-stream", "agent");
// Chat/inference path matches the @github/copilot CLI 1.0.81-6 wire identity.
assert.equal(chatHeaders["editor-version"], GITHUB_COPILOT_EDITOR_VERSION);
assert.equal(chatHeaders["editor-plugin-version"], GITHUB_COPILOT_CHAT_PLUGIN_VERSION);
assert.equal(chatHeaders["user-agent"], GITHUB_COPILOT_CHAT_USER_AGENT);
assert.equal(chatHeaders["user-agent"], GITHUB_COPILOT_CLI_USER_AGENT);
assert.equal(chatHeaders["x-github-api-version"], GITHUB_COPILOT_API_VERSION);
assert.equal(chatHeaders["copilot-integration-id"], GITHUB_COPILOT_INTEGRATION_ID);
assert.equal(chatHeaders["x-interaction-type"], GITHUB_COPILOT_INTERACTION_TYPE);
assert.equal(chatHeaders["copilot-harness-id"], GITHUB_COPILOT_HARNESS_ID);
assert.equal(chatHeaders["x-client-machine-id"], getGitHubCopilotMachineId());
assert.equal(chatHeaders["X-Initiator"], "agent");
assert.equal(chatHeaders.Accept, "text/event-stream");
// The CLI does NOT send these on inference (VS Code Copilot Chat extension only).
assert.equal(
chatHeaders["editor-plugin-version"],
undefined,
"editor-plugin-version must NOT be on the CLI inference path"
);
assert.equal(
chatHeaders["x-vscode-user-agent-library-version"],
undefined,
"x-vscode-user-agent-library-version must NOT be on the CLI inference path"
);
const internalHeaders = getGitHubCopilotInternalUserHeaders("token gh-access");
assert.equal(internalHeaders.Authorization, "token gh-access");
@@ -36,6 +56,17 @@ test("provider header profiles expose current GitHub chat and internal headers",
assert.equal(internalHeaders["X-GitHub-Api-Version"], GITHUB_COPILOT_API_VERSION);
});
test("getGitHubCopilotMachineId is stable across calls and vision toggles the vision header", () => {
// Stable per-install fingerprint: same value every call (matches the CLI).
assert.equal(getGitHubCopilotMachineId(), getGitHubCopilotMachineId());
const plain = getGitHubCopilotChatHeaders("application/json");
assert.equal(plain["copilot-vision-request"], undefined);
const vision = getGitHubCopilotChatHeaders("application/json", "user", { vision: true });
assert.equal(vision["copilot-vision-request"], "true");
// Machine id is consistent between two header builds in the same process.
assert.equal(plain["x-client-machine-id"], vision["x-client-machine-id"]);
});
test("provider header profiles expose dedicated refresh, qoder and kiro variants", () => {
const refreshHeaders = getGitHubCopilotRefreshHeaders("token gh-access");
assert.equal(refreshHeaders.Authorization, "token gh-access");

View File

@@ -14,7 +14,8 @@ import {
supportsXHighEffort,
supportsXHighEffortForMaxNormalization,
} from "../../open-sse/config/providerModels.ts";
import { GITHUB_COPILOT_MODEL_ALLOWLIST } from "../../open-sse/services/githubCopilotModels.ts";
// GITHUB_COPILOT_MODEL_ALLOWLIST is no longer used to gate the registry — the
// registry and the discovery fallback are asserted independently below.
test("provider models helpers expose model lists and defaults", () => {
const openaiModels = getProviderModels("openai");
@@ -85,25 +86,54 @@ test("Reka registry exposes preset models", () => {
test("GitHub Copilot registry reflects the current supported model lineup", () => {
const githubModels = getProviderModels("gh");
const ids = githubModels.map((model) => model.id);
const ids: string[] = githubModels.map((model) => model.id);
// The static registry and the live-discovery fallback catalog are DIFFERENT
// lists by design (the registry drives routing/targetFormat; the fallback is a
// discovery safety net), so we assert the registry's real membership directly
// rather than pinning it to GITHUB_COPILOT_MODEL_ALLOWLIST.
for (const expected of [
"claude-opus-5",
"claude-opus-4.8",
"claude-opus-4.8-fast",
"claude-opus-4.7",
"claude-opus-4.6",
"claude-sonnet-4.6",
"gemini-3.7-flash",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"grok-4.6",
"grok-4.5",
"mai-code-1-flash",
"mai-code-1.1-flash",
"mai-code-1-flash-picker",
]) {
assert.ok(ids.includes(expected), `github registry must include ${expected}`);
}
assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]);
assert.equal(getModelTargetFormat("gh", "claude-opus-5"), "claude");
assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses");
// "claude-opus-4.6" is not a real Copilot model id (unlike claude-sonnet-4.6);
// it never appears in the registry, so its target format stays null.
assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), null);
// claude-opus-4.6 IS a real Copilot model id (live /models confirms it, ctx 1M);
// it now appears in the registry and routes through the claude target format.
assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), "claude");
// Claude models route through Copilot's Anthropic-native /v1/messages shim
// (executors/github.ts) — the only endpoint that surfaces prompt-cache token
// counts for Claude and avoids a lossy tool_use/tool_result round-trip through
// the OpenAI shape. Port of decolua/9router#2608.
assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), "claude");
assert.equal(getModelTargetFormat("gh", "claude-sonnet-4.6"), "claude");
// grok/mai on Copilot are /responses-only (400 on /chat/completions).
assert.equal(getModelTargetFormat("gh", "grok-4.6"), "openai-responses");
assert.equal(getModelTargetFormat("gh", "mai-code-1.1-flash"), "openai-responses");
assert.equal(getModelTargetFormat("gh", "gpt-5.4-nano"), "openai-responses");
assert.equal(getModelTargetFormat("gh", "gemini-3.7-flash"), null);
assert.equal(getModelTargetFormat("gh", "kimi-k2.7-code"), null);
assert.equal(ids.includes("gpt-4"), false);
assert.equal(ids.includes("gpt-4o"), false);
assert.equal(ids.includes("gpt-5.4-nano"), false);
assert.equal(ids.includes("gpt-5.1"), false);
assert.equal(ids.includes("gpt-5.1-codex"), false);
assert.equal(ids.includes("claude-opus-4.1"), false);

View File

@@ -35,6 +35,36 @@ test("normalizes JSON strings before log protection and redacts sensitive keys",
});
});
test("redacts web-impersonation body credentials but preserves non-secret 'capability' diagnostics", () => {
const protectedPayload = protectPayloadForLog(
JSON.stringify({
// real browser-storage credentials that can land in a body field
cookie: "ecto_1_sess=abc123",
storageState: "{...}",
runtimeKey: "rk_live_secret",
// non-secret diagnostic fields that happen to be named 'capability' /
// 'capabilities' — must survive so call-log artifacts stay useful (#10952
// review: do not blanket-redact the generic word 'capability').
capability: "Reduced capability (fallback active)",
model: {
id: "claude-opus-4.8",
capabilities: { type: "chat", supports: { vision: true } },
},
})
);
assert.deepEqual(protectedPayload, {
cookie: "[REDACTED]",
storageState: "[REDACTED]",
runtimeKey: "[REDACTED]",
capability: "Reduced capability (fallback active)",
model: {
id: "claude-opus-4.8",
capabilities: { type: "chat", supports: { vision: true } },
},
});
});
test("omits encrypted reasoning values from structured log payloads", () => {
const encryptedContent = "encrypted".repeat(128);
const payload = {

View File

@@ -30,6 +30,50 @@ test("upstream error passthrough", async (t) => {
assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false);
}
);
await t.test(
"corpo que ecoa uma credencial (Bearer/api_key/sk-) NÃO é elegível (#secret-leak hardening)",
() => {
// Some providers echo the offending request inside a 400/422 validation
// body. Passthrough must refuse so the key is not relayed to the client.
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "invalid request: Authorization: Bearer sk-live-abc123def456ghi" },
}),
false
);
assert.equal(
shouldPassthroughUpstreamError(422, {
error: { message: "bad field", received: { api_key: "sk-abc123def456" } },
}),
false
);
assert.equal(
shouldPassthroughUpstreamError(429, {
error: { message: 'rejected: {"api-key":"xyzabc123secret"}' },
}),
false
);
}
);
await t.test(
"corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)",
() => {
// The common case must still relay verbatim so Claude Code can match the
// wording to auto-disable capabilities.
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "thinking.type: adaptive is not supported" },
}),
true
);
assert.equal(
shouldPassthroughUpstreamError(429, {
error: { type: "rate_limit_error", message: "slow down, retry after 60s" },
}),
true
);
}
);
await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => {
const body = {
type: "error",