refactor: standardized outbound User-Agent headers on shared utility constant

- Define a centralized `USER_AGENT` constant in `@oh-my-pi/pi-utils` formatted as `omp/<version>`.
- Replace hardcoded and platform-specific user agent strings across AI providers, catalog scrapers, tools, and search providers with the unified `USER_AGENT`.
- Add unit tests for update-cli binary release distribution gating.
This commit is contained in:
can1357
2026-08-11 15:38:32 +02:00
parent f22cb606bf
commit b524dfe36f
28 changed files with 97 additions and 47 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Standardized first-party outbound User-Agent headers on `omp/<version>` via the shared `USER_AGENT` utility.
### Fixed
- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107) by [@svperfecta](https://github.com/svperfecta)).
@@ -1,4 +1,3 @@
import * as os from "node:os";
import { scheduler } from "node:timers/promises";
import { type } from "@oh-my-pi/omptype";
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
@@ -19,8 +18,8 @@ import {
parseStreamingJson,
readSseJson,
structuredCloneJSON,
USER_AGENT,
} from "@oh-my-pi/pi-utils";
import packageJson from "../../package.json" with { type: "json" };
import * as AIError from "../error";
import { getEnvApiKey, isOfficialCodexApiUrl } from "../stream";
import type {
@@ -4250,7 +4249,7 @@ function createCodexHeaders(
headers.set(OPENAI_HEADERS.BETA, betaHeader);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
headers.set(OPENAI_HEADERS.VERSION, codexClientVersion);
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
headers.set("User-Agent", USER_AGENT);
if (sessionId) {
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
+2 -1
View File
@@ -19,6 +19,7 @@
* share one wire contract.
*/
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import type { FetchImpl } from "../types";
import { isRecord } from "../utils";
import { normalizeCodexBaseUrl } from "./openai-codex-base-url";
@@ -89,7 +90,7 @@ function buildUrl(baseUrl: string | undefined, routePath: string): string {
function buildHeaders(auth: CodexResetAuth, json: boolean): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.accessToken}`,
"User-Agent": "OpenCode-Status-Plugin/1.0",
"User-Agent": USER_AGENT,
};
if (auth.accountId) headers["ChatGPT-Account-Id"] = auth.accountId;
if (json) headers["Content-Type"] = "application/json";
+2 -1
View File
@@ -1,5 +1,6 @@
import { Buffer } from "node:buffer";
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import type {
CredentialRankingContext,
CredentialRankingStrategy,
@@ -417,7 +418,7 @@ export const openaiCodexUsageProvider: UsageProvider = {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "OpenCode-Status-Plugin/1.0",
"User-Agent": USER_AGENT,
};
if (accountId) {
headers["ChatGPT-Account-Id"] = accountId;
+2 -1
View File
@@ -1,4 +1,5 @@
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import type {
CredentialRankingStrategy,
UsageAmount,
@@ -228,7 +229,7 @@ async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext):
const headers: Record<string, string> = {
Authorization: token,
"Content-Type": "application/json",
"User-Agent": "OpenCode-Status-Plugin/1.0",
"User-Agent": USER_AGENT,
};
let payload: ZaiQuotaPayload | null = null;
+2 -2
View File
@@ -1,8 +1,8 @@
import packageJson from "../../package.json" with { type: "json" };
import { USER_AGENT } from "@oh-my-pi/pi-utils";
export function getOpenRouterHeaders(): Record<string, string> {
return {
"User-Agent": `omp/${packageJson.version}`,
"User-Agent": USER_AGENT,
"HTTP-Referer": "https://omp.sh/",
"X-OpenRouter-Title": "omp",
"X-OpenRouter-Categories": "cli-agent",
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Standardized catalog discovery User-Agent headers on `omp/<version>` via the shared `USER_AGENT` utility.
### Fixed
- Marked `meta/muse-spark-1.2` and `muse-spark-1.2-contributor` as image-capable (`input: ["text", "image"]`) with the same Responses reasoning, thinking, and cost metadata as `muse-spark-1.1` (contributor uses its discounted 0.1/0.2 pricing), so `omp models` no longer lists them as text-only.
@@ -1,4 +1,4 @@
import { VERSION } from "@oh-my-pi/pi-utils";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import * as logger from "@oh-my-pi/pi-utils/logger";
import {
fetchOpenAICompatibleModels,
@@ -111,7 +111,7 @@ const catalogSession: {
hasPayload: boolean;
} = { inflight: null, payload: undefined, etag: null, hasPayload: false };
const CATALOG_USER_AGENT = `omp/${VERSION} (+https://omp.sh)`;
const CATALOG_USER_AGENT = USER_AGENT;
/**
* Fetches the models.dev catalog via catalog.stencil.so, which serves a
+4
View File
@@ -22,6 +22,10 @@
- Added structured reset-reason logging to advisor context re-primes (issue #7226): every history-rewrite trigger (compact, auto-compaction, compaction-rescue, shake, drop-images, prune-tool-outputs, prune-stale-tool-results, conversation-boundary, context-maintenance) now emits an `advisor context reset` debug event with its reason, so full-transcript replays can be attributed to a concrete path.
- Added `quarantine-recovery` and `quarantine-retry-exhausted` reset reasons to advisor context-reset debug logs, so advisor full re-primes after quarantined output remain attributable without changing quarantine retry semantics (issue #7226).
### Changed
- Standardized first-party outbound User-Agent headers on `omp/<version>` via the shared `USER_AGENT` utility.
### Fixed
- Fixed `/usage`, `/advisor status`, and every other panel command answering only after the agent stopped working. Since `17.0.1` their output was queued until the turn settled (to stop mid-turn transcript mounts duplicating rows in native scrollback, issues #4806/#6767), and the deferral was silent, so on a long turn the command was indistinguishable from a dead one. The panel now renders immediately above the editor in an anchored container that is cleared and rebuilt in place, never entering the transcript, and the full output still lands in the transcript at the next settle. The preview is capped to 40% of the viewport (minimum 6 rows) so a tall report cannot push the prompt off screen.
@@ -8,10 +8,10 @@
* tests to spy on.
*/
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
import type { HindsightConfig } from "./config";
const USER_AGENT = "oh-my-pi-coding-agent";
const DEFAULT_USER_AGENT = USER_AGENT;
/** Fallback deadlines (ms) applied when the caller supplies no override. */
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -12,10 +12,6 @@ interface XAICredentials {
baseURL: string;
}
export function ohMyPiXAIUserAgent(): string {
return "oh-my-pi/xai";
}
/** xAI provider ids supported by shared HTTP tool transport resolution. */
export type XAIHttpProvider = "xai-oauth" | "xai";
+4 -4
View File
@@ -19,13 +19,13 @@ import {
ptree,
readSseJson,
Snowflake,
USER_AGENT,
untilAborted,
} from "@oh-my-pi/pi-utils";
import packageJson from "../../package.json" with { type: "json" };
import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
import { settings } from "../config/settings";
import type { CustomTool } from "../extensibility/custom-tools/types";
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
import { resolveXAIHttpCredentials } from "../lib/xai-http";
import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
import { AUTO_IMAGE_PROVIDER_ORDER, type ImageProvider, isImageProviderId } from "./image-providers";
import { resolveReadPath } from "./path-utils";
@@ -897,7 +897,7 @@ function buildOpenAIImageHeaders(model: Model, apiKey: string, sessionId: string
}
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
headers.set("User-Agent", USER_AGENT);
if (sessionId) {
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
@@ -1389,7 +1389,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"User-Agent": ohMyPiXAIUserAgent(),
"User-Agent": USER_AGENT,
},
body: JSON.stringify(xaiBody),
signal: requestSignal,
+3 -2
View File
@@ -7,9 +7,10 @@ import { type } from "@oh-my-pi/omptype";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { type ApiKey, withAuth } from "@oh-my-pi/pi-ai";
import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
import { settings } from "../config/settings";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
import { resolveXAIHttpCredentials } from "../lib/xai-http";
import { DEFAULT_TTS_LOCAL_MODEL_KEY, DEFAULT_TTS_VOICE, isTtsLocalModelKey, KOKORO_VOICES } from "../tts/models";
import { ttsClient } from "../tts/tts-client";
import { encodeWav } from "../tts/wav";
@@ -150,7 +151,7 @@ async function synthesizeXai(
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"User-Agent": ohMyPiXAIUserAgent(),
"User-Agent": USER_AGENT,
},
body: JSON.stringify(payload),
signal: combinedSignal,
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
import { $which, getToolsDir, logger, ptree, TempDir, USER_AGENT } from "@oh-my-pi/pi-utils";
import { extractArchive } from "./zip";
const TOOLS_DIR = getToolsDir();
@@ -174,7 +174,7 @@ async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<str
let response: Response;
try {
response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
headers: { "User-Agent": USER_AGENT },
signal: ptree.combineSignals(signal, TOOL_METADATA_TIMEOUT_MS),
});
} catch (err) {
@@ -1,4 +1,4 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatNumber, loadPage, looksLikeHtml } from "./types";
@@ -26,7 +26,7 @@ export const handleCratesIo: SpecialHandler = async (
const result = await loadPage(apiUrl, {
timeout,
signal,
headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
headers: { "User-Agent": USER_AGENT },
});
if (!result.ok) return null;
@@ -5,7 +5,7 @@
* API docs: https://www.discogs.com/developers
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, loadPage } from "./types";
@@ -277,7 +277,7 @@ export const handleDiscogs: SpecialHandler = async (
signal,
headers: {
Accept: "application/json",
"User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
"User-Agent": USER_AGENT,
},
});
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { gunzipSync } from "node:zlib";
import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson } from "@oh-my-pi/pi-utils";
import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import { ToolAbortError } from "../../tools/tool-errors";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, MAX_BYTES } from "./types";
@@ -388,7 +388,7 @@ export const handleDocsRs: SpecialHandler = async (
const requestSignal = ptree.combineSignals(signal, timeout * 1000);
const response = await fetch(jsonUrl, {
signal: requestSignal,
headers: { "User-Agent": "omp-web-fetch/1.0", Accept: "application/gzip" },
headers: { "User-Agent": USER_AGENT, Accept: "application/gzip" },
redirect: "follow",
});
if (!response.ok) return null;
@@ -1,4 +1,4 @@
import { $env, ptree } from "@oh-my-pi/pi-utils";
import { $env, ptree, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatMediaDuration, loadPage } from "./types";
@@ -121,7 +121,7 @@ export async function fetchGitHubApi(
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "omp-web-fetch/1.0",
"User-Agent": USER_AGENT,
};
// Use GITHUB_TOKEN if available
@@ -2,7 +2,7 @@
* MusicBrainz URL handler for artists, releases, and recordings
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, formatMediaDuration, loadPage } from "./types";
@@ -64,7 +64,6 @@ interface MusicBrainzRelease {
}
const MUSICBRAINZ_HOSTS = new Set(["musicbrainz.org", "www.musicbrainz.org"]);
const USER_AGENT = "omp-web-fetch/1.0 (https://github.com/anthropics)";
const MAX_TRACKS = 50;
function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | null {
@@ -1,12 +1,12 @@
/**
* PubMed handler for web-fetch
*/
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import { buildResult, loadPage, type RenderResult, type SpecialHandler } from "./types";
const NCBI_HEADERS = {
Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
"User-Agent": "CodingAgent/1.0 (web scraper)",
"User-Agent": USER_AGENT,
};
/**
@@ -1,4 +1,4 @@
import { tryParseJson } from "@oh-my-pi/pi-utils";
import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { RenderResult, SpecialHandler } from "./types";
import { buildResult, loadPage } from "./types";
@@ -178,7 +178,7 @@ export const handleSecEdgar: SpecialHandler = async (
timeout,
signal,
headers: {
"User-Agent": "CodingAgent/1.0 (research tool)",
"User-Agent": USER_AGENT,
Accept: "application/json",
},
});
@@ -4,7 +4,6 @@
* Uses the configured Codex Responses transport for proxy/API-key setups and
* the official ChatGPT backend for OAuth logins.
*/
import * as os from "node:os";
import {
type AuthStorage,
type FetchImpl,
@@ -22,8 +21,7 @@ import {
OPENAI_HEADER_VALUES,
OPENAI_HEADERS,
} from "@oh-my-pi/pi-catalog/wire/codex";
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
import packageJson from "../../../../package.json" with { type: "json" };
import { $env, readSseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../../../config/model-registry";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
@@ -441,7 +439,7 @@ function buildCodexHeaders(
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
headers.set(OPENAI_HEADERS.VERSION, CODEX_CLIENT_VERSION);
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
headers.set("User-Agent", USER_AGENT);
headers.set("Accept", "text/event-stream");
headers.set("Content-Type", "application/json");
return headers;
@@ -14,7 +14,7 @@ import {
getAntigravityUserAgent,
getGeminiCliHeaders,
} from "@oh-my-pi/pi-catalog/wire/gemini-headers";
import { fetchWithRetry } from "@oh-my-pi/pi-utils";
import { fetchWithRetry, USER_AGENT } from "@oh-my-pi/pi-utils";
import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
@@ -436,8 +436,8 @@ async function callGeminiSearch(
requestId: `agent-${crypto.randomUUID()}`,
}
: {
userAgent: "pi-coding-agent",
requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
userAgent: USER_AGENT,
requestId: `omp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
};
const normalizedSystemPrompt = systemPrompt?.toWellFormed();
@@ -9,7 +9,7 @@ import {
imageGenTool,
setImageProviderOrder,
} from "@oh-my-pi/pi-coding-agent/tools/image-gen";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { removeWithRetries, USER_AGENT } from "@oh-my-pi/pi-utils";
const originalOpenRouterKey = Bun.env.OPENROUTER_API_KEY;
const generatedImagePaths: string[] = [];
@@ -618,7 +618,7 @@ describe("imageGenTool", () => {
expect(requestUrl).toBe("https://api.x.ai/v1/images/generations");
expect(captured.authorization).toBe("Bearer test-xai-token");
expect(captured.userAgent).toBe("oh-my-pi/xai");
expect(captured.userAgent).toBe(USER_AGENT);
expect(requestBody).toMatchObject({
model: "grok-imagine-image",
prompt: "a cat.",
@@ -19,7 +19,9 @@ import {
replaceBinaryForUpdate,
resolveBunGlobalNodeModulesDirFromLocations,
resolveReleaseBinaryAsset,
resolveReleaseDist,
resolveUpdateMethodForTest,
shouldForceBinaryUpdate,
sweepStaleBackups,
updateViaBinaryAt,
} from "@oh-my-pi/pi-coding-agent/cli/update-cli";
@@ -740,3 +742,38 @@ describe("update-cli stale backup sweep", () => {
expect(await Bun.file(`${targetPath}.config.bak`).exists()).toBe(true);
});
});
describe("update-cli binary-only release gating", () => {
it("honors an explicit omp.dist field from the registry manifest", () => {
expect(resolveReleaseDist({ omp: { dist: "binary" } })).toBe("binary");
expect(resolveReleaseDist({ omp: { dist: "npm" } })).toBe("npm");
});
it("treats unknown dist values as binary-only", () => {
expect(resolveReleaseDist({ omp: { dist: "cargo" } })).toBe("binary");
});
it("returns undefined when the manifest carries no dist field", () => {
expect(resolveReleaseDist({ version: "1.2.3" })).toBeUndefined();
expect(resolveReleaseDist({ omp: {} })).toBeUndefined();
expect(resolveReleaseDist(undefined)).toBeUndefined();
});
it("forces binary updates when dist is binary regardless of version", () => {
expect(shouldForceBinaryUpdate({ version: "1.2.3", dist: "binary" }, "1.2.2")).toBe(true);
});
it("allows package-manager updates across majors when dist is explicitly npm", () => {
expect(shouldForceBinaryUpdate({ version: "2.0.0", dist: "npm" }, "1.9.0")).toBe(false);
});
it("forces binary updates on a major bump without a dist field", () => {
expect(shouldForceBinaryUpdate({ version: "2.0.0" }, "1.9.0")).toBe(true);
expect(shouldForceBinaryUpdate({ version: "2.0.0-rc.1" }, "1.9.0")).toBe(true);
});
it("keeps package-manager updates within the same major and on downgrades", () => {
expect(shouldForceBinaryUpdate({ version: "1.10.0" }, "1.9.0")).toBe(false);
expect(shouldForceBinaryUpdate({ version: "1.0.0" }, "2.0.0")).toBe(false);
});
});
+1
View File
@@ -5,6 +5,7 @@
### Changed
- Changed stale process-log retention from the newest five files globally to one newest file per completed process and day within the current and previous four local calendar days. This preserves bounded daily diagnostic coverage while continuing to remove one-use audit files.
- Changed outbound User-Agent consumers to share the versioned `USER_AGENT` constant (`omp/<version>`).
### Fixed
+3
View File
@@ -28,6 +28,9 @@ export const MAIN_CONFIG_FILENAMES = ["config.yml", "config.yaml"] as const;
/** Version (e.g. "1.0.0") */
export const VERSION: string = version;
/** Default User-Agent header string (e.g. "omp/17.2.12") */
export const USER_AGENT = `omp/${VERSION}`;
/** Minimum Bun version */
export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
+2 -1
View File
@@ -14,6 +14,7 @@
*/
import * as path from "node:path";
import { USER_AGENT } from "@oh-my-pi/pi-utils";
const PROVIDER_FILE = path.join(import.meta.dir, "../packages/catalog/src/wire/gemini-headers.ts");
@@ -36,7 +37,7 @@ async function fetchLatestGitHubRelease(
try {
// /releases/latest only returns non-prerelease, non-draft releases
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "oh-my-pi/version-check" },
headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT },
});
if (!res.ok) return null;
const data = (await res.json()) as { tag_name?: string };