mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
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:
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal 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
|
||||
19
Dockerfile
19
Dockerfile
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
1
changelog.d/fixes/secret-leak-error-surface-hardening.md
Normal file
1
changelog.d/fixes/secret-leak-error-surface-hardening.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
26
src/lib/buildPhase.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
src/lib/db/better-sqlite3.stub.js
Normal file
31
src/lib/db/better-sqlite3.stub.js
Normal 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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
69
tests/unit/build/10060-build-sqlite-stub.test.ts
Normal file
69
tests/unit/build/10060-build-sqlite-stub.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
71
tests/unit/copilot-claude-always-v1-messages.test.ts
Normal file
71
tests/unit/copilot-claude-always-v1-messages.test.ts
Normal 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$/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
77
tests/unit/github-copilot-discovery-token.test.ts
Normal file
77
tests/unit/github-copilot-discovery-token.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
98
tests/unit/noauth-sibling-compat-override-7620.test.ts
Normal file
98
tests/unit/noauth-sibling-compat-override-7620.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user