Merge remote-tracking branch 'origin/main' into farm/8134d5cd/fix-revived-yield-loop
This commit is contained in:
Generated
+7
-7
@@ -1850,9 +1850,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.27"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
|
||||
checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
@@ -2881,7 +2881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2950,7 +2950,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2962,7 +2962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -3015,7 +3015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
@@ -3064,7 +3064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-walker"
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"globset",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/vendor/brush-core", "crates/vendor/brush-builtins"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "16.3.13"
|
||||
version = "16.3.15"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -174,7 +174,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"handlebars": "catalog:",
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -338,18 +338,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.3.13",
|
||||
"@oh-my-pi/omp-stats": "16.3.13",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.13",
|
||||
"@oh-my-pi/pi-ai": "16.3.13",
|
||||
"@oh-my-pi/pi-catalog": "16.3.13",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.13",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.13",
|
||||
"@oh-my-pi/pi-natives": "16.3.13",
|
||||
"@oh-my-pi/pi-tui": "16.3.13",
|
||||
"@oh-my-pi/pi-utils": "16.3.13",
|
||||
"@oh-my-pi/pi-wire": "16.3.13",
|
||||
"@oh-my-pi/snapcompact": "16.3.13",
|
||||
"@oh-my-pi/hashline": "16.3.15",
|
||||
"@oh-my-pi/omp-stats": "16.3.15",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.15",
|
||||
"@oh-my-pi/pi-ai": "16.3.15",
|
||||
"@oh-my-pi/pi-catalog": "16.3.15",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.15",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.15",
|
||||
"@oh-my-pi/pi-natives": "16.3.15",
|
||||
"@oh-my-pi/pi-tui": "16.3.15",
|
||||
"@oh-my-pi/pi-utils": "16.3.15",
|
||||
"@oh-my-pi/pi-wire": "16.3.15",
|
||||
"@oh-my-pi/snapcompact": "16.3.15",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -791,7 +791,7 @@
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.9.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.9.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/sdk-trace-base": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.42.0", "", {}, "sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="],
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV16_3_13")]
|
||||
#[napi(js_name = "__piNativesV16_3_15")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
+12
-12
@@ -25,18 +25,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.3.13",
|
||||
"@oh-my-pi/omp-stats": "16.3.13",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.13",
|
||||
"@oh-my-pi/pi-ai": "16.3.13",
|
||||
"@oh-my-pi/pi-catalog": "16.3.13",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.13",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.13",
|
||||
"@oh-my-pi/pi-natives": "16.3.13",
|
||||
"@oh-my-pi/pi-tui": "16.3.13",
|
||||
"@oh-my-pi/pi-utils": "16.3.13",
|
||||
"@oh-my-pi/pi-wire": "16.3.13",
|
||||
"@oh-my-pi/snapcompact": "16.3.13",
|
||||
"@oh-my-pi/hashline": "16.3.15",
|
||||
"@oh-my-pi/omp-stats": "16.3.15",
|
||||
"@oh-my-pi/pi-agent-core": "16.3.15",
|
||||
"@oh-my-pi/pi-ai": "16.3.15",
|
||||
"@oh-my-pi/pi-catalog": "16.3.15",
|
||||
"@oh-my-pi/pi-coding-agent": "16.3.15",
|
||||
"@oh-my-pi/pi-mnemopi": "16.3.15",
|
||||
"@oh-my-pi/pi-natives": "16.3.15",
|
||||
"@oh-my-pi/pi-tui": "16.3.15",
|
||||
"@oh-my-pi/pi-utils": "16.3.15",
|
||||
"@oh-my-pi/pi-wire": "16.3.15",
|
||||
"@oh-my-pi/snapcompact": "16.3.15",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { Api, FetchImpl, Model } from "@oh-my-pi/pi-ai";
|
||||
import { isTransientStatus, ProviderHttpError } from "@oh-my-pi/pi-ai/error";
|
||||
import {
|
||||
getOpenAIResponsesPromptCacheKey,
|
||||
getOpenAIPromptCacheKey,
|
||||
getOpenAIResponsesRoutingSessionId,
|
||||
parseAzureDeploymentNameMap,
|
||||
resolveOpenAIRequestSetup,
|
||||
@@ -269,7 +269,7 @@ async function attemptCompactionV2Streaming(
|
||||
// of an otherwise-normal Responses request, then stream the result. `store`
|
||||
// stays false — compaction must never persist a server-side response object.
|
||||
const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
|
||||
const promptCacheKey = getOpenAIResponsesPromptCacheKey(cacheOptions);
|
||||
const promptCacheKey = getOpenAIPromptCacheKey(cacheOptions);
|
||||
const body: Record<string, unknown> = {
|
||||
model: request.model,
|
||||
input: [...request.input, COMPACTION_TRIGGER_ITEM],
|
||||
@@ -311,7 +311,7 @@ function buildCompactionV2Headers(model: Model, apiKey: string, request: Compact
|
||||
const api = compactionV2Api(model);
|
||||
const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
|
||||
const routingSessionId = getOpenAIResponsesRoutingSessionId(cacheOptions);
|
||||
const promptCacheSessionId = getOpenAIResponsesPromptCacheKey(cacheOptions);
|
||||
const promptCacheSessionId = getOpenAIPromptCacheKey(cacheOptions);
|
||||
const headers: Record<string, string> =
|
||||
api === "azure-openai-responses"
|
||||
? {
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.15] - 2026-07-09
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `OpenAIResponsesCacheOptions`, `normalizeOpenAIResponsesPromptCacheKey`, and `getOpenAIResponsesPromptCacheKey` to the endpoint-neutral `OpenAICacheOptions`, `normalizeOpenAIPromptCacheKey`, and `getOpenAIPromptCacheKey`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added automatic prompt-cache affinity header injection for OpenAI-family chat completions
|
||||
- Added support for explicit prompt-cache affinity headers in OpenAI-family chat completions
|
||||
- Added OpenAI pro reasoning mode support: models carrying the catalog `reasoningMode: "pro"` marker (GPT-5.6 Pro aliases) send `reasoning: { mode: "pro" }` on OpenAI Responses and Codex Responses requests, alongside the configured effort. The Codex request body now honors `requestModelId` so catalog aliases request the base upstream model id.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated xAI OAuth to use a dedicated device-code flow instead of redirect/loopback server
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved account routing for GPT-5.6 models to better respect paid tier requirements
|
||||
- Refined account selection logic to correctly identify plan types from account metadata
|
||||
- Fixed OpenAI Codex multi-account routing for GPT-5.6: Sol and Luna requests now prefer Plus-or-higher accounts while Terra remains available to Free/Go accounts; local pro-mode aliases inherit their base model's Codex plan eligibility.
|
||||
- Fixed xAI Grok OAuth login to use xAI's device authorization flow: `/login` now opens the verification URL, displays the device code, and polls for approval instead of asking for a pasted redirect or linking to Hermes Agent documentation.
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Codex reasoning effort mapping to support shifted wire tiers for newer models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the Codex Responses request transformer bypassing catalog/compat reasoning effort maps: the clamped user effort is now remapped to the provider wire tier (GPT-5.6's shifted five-tier scale sends `max` for user `xhigh` and `xhigh` for `high`), failing loudly if a map produces a value outside the Codex wire vocabulary.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -112,8 +112,8 @@ function deriveSessionId(modelId: string, context: Context): string {
|
||||
parts.push(JSON.stringify({ role: first.role, content: first.content }));
|
||||
}
|
||||
const seed = parts.join("\u0000");
|
||||
// The 36-char UUID flows through unchanged: Codex's
|
||||
// `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim.
|
||||
// The 36-char UUID flows through unchanged:
|
||||
// `normalizeOpenAIPromptCacheKey` accepts ≤64 chars verbatim.
|
||||
return deterministicUuid(seed);
|
||||
}
|
||||
|
||||
|
||||
+104
-40
@@ -761,25 +761,82 @@ function isAbortSignalOption(
|
||||
return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
|
||||
}
|
||||
|
||||
function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
|
||||
return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
|
||||
type OpenAICodexPlanRequirement = "none" | "paid" | "pro";
|
||||
type OpenAICodexPlanClass = "free" | "paid" | "pro" | "unknown";
|
||||
|
||||
const GPT_56_PAID_CODEX_MODEL_PATTERN = /^gpt-5\.6-(?:sol|luna)(?:-pro)?$/;
|
||||
const OPENAI_CODEX_PRO_PLAN_TOKENS: Record<string, true> = {
|
||||
pro: true,
|
||||
};
|
||||
const OPENAI_CODEX_PAID_PLAN_TOKENS: Record<string, true> = {
|
||||
plus: true,
|
||||
business: true,
|
||||
team: true,
|
||||
enterprise: true,
|
||||
edu: true,
|
||||
education: true,
|
||||
teacher: true,
|
||||
teachers: true,
|
||||
health: true,
|
||||
gov: true,
|
||||
government: true,
|
||||
};
|
||||
const OPENAI_CODEX_FREE_PLAN_TOKENS: Record<string, true> = {
|
||||
free: true,
|
||||
go: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Account tier needed for model-aware Codex OAuth routing.
|
||||
*
|
||||
* GPT-5.6 Terra (including its local pro-mode alias) remains available on every
|
||||
* plan. Sol and Luna pro-mode aliases inherit their base models' paid tier;
|
||||
* only Spark currently has a documented Pro-plan preference in Codex.
|
||||
*/
|
||||
function resolveOpenAICodexPlanRequirement(provider: string, modelId: string | undefined): OpenAICodexPlanRequirement {
|
||||
if (provider !== "openai-codex" || typeof modelId !== "string") return "none";
|
||||
const separator = modelId.lastIndexOf("/");
|
||||
const bareModelId = (separator === -1 ? modelId : modelId.slice(separator + 1)).toLowerCase();
|
||||
if (bareModelId.includes("-spark")) return "pro";
|
||||
if (bareModelId === "gpt-5.6" || GPT_56_PAID_CODEX_MODEL_PATTERN.test(bareModelId)) return "paid";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function getUsagePlanType(report: UsageReport | null): string | undefined {
|
||||
const metadata = report?.metadata;
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
||||
const planType = (metadata as { planType?: unknown }).planType;
|
||||
return typeof planType === "string" ? planType.toLowerCase() : undefined;
|
||||
if (!metadata) return undefined;
|
||||
const planType = metadata.planType;
|
||||
if (typeof planType !== "string") return undefined;
|
||||
const normalized = planType
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s-]+/g, "_");
|
||||
return normalized.startsWith("chatgpt_") ? normalized.slice("chatgpt_".length) : normalized;
|
||||
}
|
||||
|
||||
function getOpenAICodexPlanPriority(report: UsageReport | null): number {
|
||||
function classifyOpenAICodexPlan(report: UsageReport | null): OpenAICodexPlanClass {
|
||||
const planType = getUsagePlanType(report);
|
||||
if (!planType) return 1;
|
||||
return planType.includes("pro") ? 0 : 2;
|
||||
if (!planType) return "unknown";
|
||||
const tokens = planType.split("_");
|
||||
if (tokens.some(token => OPENAI_CODEX_PRO_PLAN_TOKENS[token] === true)) return "pro";
|
||||
if (tokens.some(token => OPENAI_CODEX_PAID_PLAN_TOKENS[token] === true)) return "paid";
|
||||
if (tokens.some(token => OPENAI_CODEX_FREE_PLAN_TOKENS[token] === true)) return "free";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
|
||||
return getUsagePlanType(report)?.includes("pro") === true;
|
||||
function getOpenAICodexPlanEligibility(
|
||||
report: UsageReport | null,
|
||||
requirement: OpenAICodexPlanRequirement,
|
||||
): boolean | undefined {
|
||||
if (requirement === "none") return true;
|
||||
const planClass = classifyOpenAICodexPlan(report);
|
||||
if (planClass === "unknown") return undefined;
|
||||
return requirement === "paid" ? planClass !== "free" : planClass === "pro";
|
||||
}
|
||||
|
||||
function getOpenAICodexPlanPriority(report: UsageReport | null, requirement: OpenAICodexPlanRequirement): number {
|
||||
const eligibility = getOpenAICodexPlanEligibility(report, requirement);
|
||||
return eligibility === true ? 0 : eligibility === undefined ? 1 : 2;
|
||||
}
|
||||
|
||||
function compareUsageRankingMetric(left: number, right: number): number {
|
||||
@@ -3186,8 +3243,7 @@ export class AuthStorage {
|
||||
#compareRankedOAuthCandidatePriority(
|
||||
left: RankedOAuthCandidate,
|
||||
right: RankedOAuthCandidate,
|
||||
provider: string,
|
||||
modelId: string | undefined,
|
||||
planRequirement: OpenAICodexPlanRequirement,
|
||||
): number {
|
||||
if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
|
||||
if (left.blocked && right.blocked) {
|
||||
@@ -3196,7 +3252,7 @@ export class AuthStorage {
|
||||
if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
|
||||
return 0;
|
||||
}
|
||||
if (requiresOpenAICodexProModel(provider, modelId) && left.planPriority !== right.planPriority) {
|
||||
if (planRequirement !== "none" && left.planPriority !== right.planPriority) {
|
||||
return left.planPriority - right.planPriority;
|
||||
}
|
||||
if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
|
||||
@@ -3214,20 +3270,18 @@ export class AuthStorage {
|
||||
#compareRankedOAuthCandidates(
|
||||
left: RankedOAuthCandidate,
|
||||
right: RankedOAuthCandidate,
|
||||
provider: string,
|
||||
modelId: string | undefined,
|
||||
planRequirement: OpenAICodexPlanRequirement,
|
||||
): number {
|
||||
const priority = this.#compareRankedOAuthCandidatePriority(left, right, provider, modelId);
|
||||
const priority = this.#compareRankedOAuthCandidatePriority(left, right, planRequirement);
|
||||
return priority !== 0 ? priority : left.orderPos - right.orderPos;
|
||||
}
|
||||
|
||||
#orderRankedOAuthCandidates(
|
||||
candidates: RankedOAuthCandidate[],
|
||||
sessionId: string | undefined,
|
||||
provider: string,
|
||||
modelId: string | undefined,
|
||||
planRequirement: OpenAICodexPlanRequirement,
|
||||
): OAuthCandidate[] {
|
||||
candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, provider, modelId));
|
||||
candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, planRequirement));
|
||||
if (!sessionId) {
|
||||
return candidates.map(candidate => ({
|
||||
selection: candidate.selection,
|
||||
@@ -3252,7 +3306,7 @@ export class AuthStorage {
|
||||
for (const candidate of unblocked) {
|
||||
if (
|
||||
candidate !== previous &&
|
||||
this.#compareRankedOAuthCandidatePriority(previous, candidate, provider, modelId) !== 0
|
||||
this.#compareRankedOAuthCandidatePriority(previous, candidate, planRequirement) !== 0
|
||||
) {
|
||||
bucketIndex += 1;
|
||||
}
|
||||
@@ -3297,6 +3351,7 @@ export class AuthStorage {
|
||||
providerKey: string;
|
||||
provider: string;
|
||||
order: number[];
|
||||
planRequirement: OpenAICodexPlanRequirement;
|
||||
credentials: OAuthSelection[];
|
||||
options?: AuthApiKeyOptions;
|
||||
sessionId?: string;
|
||||
@@ -3378,7 +3433,7 @@ export class AuthStorage {
|
||||
blocked,
|
||||
blockedUntil,
|
||||
hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
|
||||
planPriority: getOpenAICodexPlanPriority(usage),
|
||||
planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
|
||||
secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
|
||||
secondaryDrainRate: this.#computeWindowDrainRate(
|
||||
secondaryTarget,
|
||||
@@ -3390,7 +3445,7 @@ export class AuthStorage {
|
||||
orderPos,
|
||||
});
|
||||
}
|
||||
return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.provider, args.options?.modelId);
|
||||
return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.planRequirement);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3418,8 +3473,9 @@ export class AuthStorage {
|
||||
const strategy = this.#rankingStrategyResolver?.(provider);
|
||||
const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
|
||||
const blockScope = strategy?.blockScope?.(rankingContext);
|
||||
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
|
||||
const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
|
||||
const planRequirement = resolveOpenAICodexPlanRequirement(provider, options?.modelId);
|
||||
const hasPlanRequirement = planRequirement !== "none";
|
||||
const checkUsage = strategy !== undefined && (credentials.length > 1 || hasPlanRequirement);
|
||||
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
||||
const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
|
||||
const sessionPreferredCredential =
|
||||
@@ -3438,12 +3494,13 @@ export class AuthStorage {
|
||||
sessionPreferredIndex !== undefined &&
|
||||
sessionPreferredCanRefreshOrUse &&
|
||||
!this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
|
||||
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
|
||||
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || hasPlanRequirement);
|
||||
const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
|
||||
const candidates = shouldRank
|
||||
? await this.#rankOAuthSelections({
|
||||
providerKey,
|
||||
provider,
|
||||
planRequirement,
|
||||
order: rankingOrder,
|
||||
credentials,
|
||||
options,
|
||||
@@ -3457,7 +3514,7 @@ export class AuthStorage {
|
||||
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
||||
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
||||
|
||||
if (sessionPreferredIndex !== undefined && !requiresProModel) {
|
||||
if (sessionPreferredIndex !== undefined && !hasPlanRequirement) {
|
||||
const sessionPreferredCandidate = candidates.findIndex(
|
||||
candidate =>
|
||||
!this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
|
||||
@@ -3537,10 +3594,12 @@ export class AuthStorage {
|
||||
}),
|
||||
);
|
||||
|
||||
// Skip the Pro-plan filter when no candidate is confirmed Pro, so users with only
|
||||
// non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
|
||||
const enforceProRequirement =
|
||||
requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
|
||||
// Enforce a tier only when at least one account is confirmed eligible. If
|
||||
// every report is unknown or ineligible, preserve trial/grandfathered access
|
||||
// by allowing the normal candidate fallback to attempt the request.
|
||||
const enforcePlanRequirement =
|
||||
hasPlanRequirement &&
|
||||
candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
|
||||
|
||||
const fallback = candidates[0];
|
||||
|
||||
@@ -3556,7 +3615,8 @@ export class AuthStorage {
|
||||
allowBlocked: false,
|
||||
prefetchedUsage: candidate.usage,
|
||||
usagePrechecked: candidate.usageChecked,
|
||||
enforceProRequirement,
|
||||
planRequirement,
|
||||
enforcePlanRequirement,
|
||||
strategy,
|
||||
rankingContext,
|
||||
blockScope,
|
||||
@@ -3571,7 +3631,8 @@ export class AuthStorage {
|
||||
allowBlocked: true,
|
||||
prefetchedUsage: fallback.usage,
|
||||
usagePrechecked: fallback.usageChecked,
|
||||
enforceProRequirement,
|
||||
planRequirement,
|
||||
enforcePlanRequirement,
|
||||
strategy,
|
||||
rankingContext,
|
||||
blockScope,
|
||||
@@ -3709,7 +3770,8 @@ export class AuthStorage {
|
||||
allowBlocked: boolean;
|
||||
prefetchedUsage?: UsageReport | null;
|
||||
usagePrechecked?: boolean;
|
||||
enforceProRequirement?: boolean;
|
||||
planRequirement?: OpenAICodexPlanRequirement;
|
||||
enforcePlanRequirement?: boolean;
|
||||
strategy?: CredentialRankingStrategy;
|
||||
rankingContext?: CredentialRankingContext;
|
||||
blockScope?: string;
|
||||
@@ -3722,7 +3784,8 @@ export class AuthStorage {
|
||||
allowBlocked,
|
||||
prefetchedUsage = null,
|
||||
usagePrechecked = false,
|
||||
enforceProRequirement,
|
||||
planRequirement: providedPlanRequirement,
|
||||
enforcePlanRequirement,
|
||||
strategy,
|
||||
rankingContext,
|
||||
blockScope,
|
||||
@@ -3741,12 +3804,13 @@ export class AuthStorage {
|
||||
// refresh / persist / CAS-disable addresses the row by this stable id.
|
||||
const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
|
||||
|
||||
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
|
||||
const applyProFilter = enforceProRequirement ?? requiresProModel;
|
||||
const planRequirement = providedPlanRequirement ?? resolveOpenAICodexPlanRequirement(provider, options?.modelId);
|
||||
const hasPlanRequirement = planRequirement !== "none";
|
||||
const applyPlanFilter = enforcePlanRequirement ?? hasPlanRequirement;
|
||||
let usage: UsageReport | null = null;
|
||||
let usageChecked = false;
|
||||
|
||||
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
||||
if ((checkUsage && !allowBlocked) || hasPlanRequirement) {
|
||||
if (usagePrechecked) {
|
||||
usage = prefetchedUsage;
|
||||
usageChecked = true;
|
||||
@@ -3757,7 +3821,7 @@ export class AuthStorage {
|
||||
});
|
||||
usageChecked = true;
|
||||
}
|
||||
if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
|
||||
if (applyPlanFilter && getOpenAICodexPlanEligibility(usage, planRequirement) !== true) {
|
||||
return undefined;
|
||||
}
|
||||
if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
|
||||
@@ -3825,7 +3889,7 @@ export class AuthStorage {
|
||||
} else {
|
||||
this.#replaceCredentialAt(provider, selection.index, updated);
|
||||
}
|
||||
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
||||
if ((checkUsage && !allowBlocked) || hasPlanRequirement) {
|
||||
const sameAccount = selection.credential.accountId === updated.accountId;
|
||||
if (!usageChecked || !sameAccount) {
|
||||
usage = await this.#getUsageReport(provider, updated, {
|
||||
@@ -3834,7 +3898,7 @@ export class AuthStorage {
|
||||
});
|
||||
usageChecked = true;
|
||||
}
|
||||
if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
|
||||
if (applyPlanFilter && getOpenAICodexPlanEligibility(usage, planRequirement) !== true) {
|
||||
return undefined;
|
||||
}
|
||||
if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
applyResponsesReasoningParams,
|
||||
buildResponsesInput,
|
||||
createInitialResponsesAssistantMessage,
|
||||
getOpenAIResponsesPromptCacheKey,
|
||||
getOpenAIPromptCacheKey,
|
||||
isOpenAIResponsesProgressEvent,
|
||||
parseAzureDeploymentNameMap,
|
||||
processResponsesStream,
|
||||
@@ -348,7 +348,7 @@ function buildParams(
|
||||
model: deploymentName,
|
||||
input: messages,
|
||||
stream: true,
|
||||
prompt_cache_key: getOpenAIResponsesPromptCacheKey(options),
|
||||
prompt_cache_key: getOpenAIPromptCacheKey(options),
|
||||
// Encrypted reasoning replay (applyResponsesReasoningParams) requires
|
||||
// stateless responses, matching the openai provider.
|
||||
store: false,
|
||||
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
finalizeToolCallArgumentsDone,
|
||||
isOpenAIResponsesProgressEvent,
|
||||
mapOpenAIResponsesStopReason,
|
||||
normalizeOpenAIResponsesPromptCacheKey,
|
||||
normalizeOpenAIPromptCacheKey,
|
||||
populateResponsesUsageFromResponse,
|
||||
promoteResponsesToolUseStopReason,
|
||||
} from "./openai-shared";
|
||||
@@ -898,8 +898,8 @@ async function buildCodexRequestContext(
|
||||
const accountId = getCodexAccountId(apiKey);
|
||||
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
||||
const url = resolveCodexResponsesUrl(baseUrl);
|
||||
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
||||
const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
||||
const promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
||||
const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
||||
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
|
||||
|
||||
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
|
||||
@@ -946,10 +946,10 @@ export async function buildTransformedCodexRequestBody(
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
options: OpenAICodexResponsesOptions | undefined,
|
||||
promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
|
||||
promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
|
||||
): Promise<RequestBody> {
|
||||
const params: RequestBody = {
|
||||
model: model.id,
|
||||
model: model.requestModelId ?? model.id,
|
||||
input: convertMessages(model, context),
|
||||
stream: true,
|
||||
prompt_cache_key: promptCacheKey,
|
||||
@@ -2139,7 +2139,7 @@ export async function prewarmOpenAICodexResponses(
|
||||
const accountId = getCodexAccountId(apiKey);
|
||||
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
||||
const url = resolveCodexResponsesUrl(baseUrl);
|
||||
const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
||||
const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
||||
const promptCacheKey = transportSessionId;
|
||||
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
||||
const responsesLite = options?.responsesLite === true;
|
||||
@@ -2283,7 +2283,7 @@ function getCodexWebSocketStateForPublicSession(
|
||||
): CodexWebSocketSessionState | undefined {
|
||||
const baseUrl = options?.baseUrl || model.baseUrl || CODEX_BASE_URL;
|
||||
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
||||
const normalizedSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
||||
const normalizedSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
||||
const publicSessionKey = normalizedSessionId ? `${baseUrl}:${model.id}:${normalizedSessionId}` : undefined;
|
||||
const privateSessionKey = publicSessionKey
|
||||
? providerSessionState?.webSocketPublicToPrivate.get(publicSessionKey)
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { supportsAllTurnsReasoningContext, supportsCodexReasoningSummary } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import type { Api, Model } from "../../types";
|
||||
import type { Model } from "../../types";
|
||||
import { mapOpenAIReasoningEffort } from "../openai-shared";
|
||||
|
||||
/** Reasoning replay scope for the Codex Responses API (`reasoning.context`). */
|
||||
export type CodexReasoningContext = "auto" | "current_turn" | "all_turns";
|
||||
|
||||
/** User-facing effort levels accepted by Codex request options. */
|
||||
type CodexCallerEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
/** Caller literal → catalog `Effort` bridge (the enum is nominal). */
|
||||
const EFFORT_BY_NAME: Record<CodexCallerEffort, Effort> = {
|
||||
minimal: Effort.Minimal,
|
||||
low: Effort.Low,
|
||||
medium: Effort.Medium,
|
||||
high: Effort.High,
|
||||
xhigh: Effort.XHigh,
|
||||
};
|
||||
|
||||
export interface ReasoningConfig {
|
||||
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
summary?: "auto" | "concise" | "detailed";
|
||||
context?: CodexReasoningContext;
|
||||
/** Pro reasoning serving mode (gpt-5.6+ catalog pro aliases). */
|
||||
mode?: "pro";
|
||||
}
|
||||
|
||||
export interface CodexRequestOptions {
|
||||
reasoningEffort?: ReasoningConfig["effort"];
|
||||
/** User-facing effort; the wire-only `max` tier is reached via the model's effort map. */
|
||||
reasoningEffort?: CodexCallerEffort | "none";
|
||||
reasoningSummary?: ReasoningConfig["summary"] | null;
|
||||
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */
|
||||
reasoningContext?: CodexReasoningContext;
|
||||
@@ -80,10 +96,40 @@ export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolea
|
||||
return requested === true && !containsInputImage(body.input);
|
||||
}
|
||||
|
||||
function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
|
||||
/**
|
||||
* Clamp a user-facing effort to the model's ladder, then remap to the wire
|
||||
* tier (e.g. GPT-5.6's shifted five-tier scale sends `max` for user `xhigh`).
|
||||
* A mapped value outside the Codex wire vocabulary is a broken compat/model
|
||||
* effort map — fail loudly rather than silently sending a different tier.
|
||||
*/
|
||||
function mapCodexWireEffort(
|
||||
model: Model<"openai-codex-responses">,
|
||||
effort: CodexCallerEffort,
|
||||
): ReasoningConfig["effort"] {
|
||||
const mapped = mapOpenAIReasoningEffort(model, model.compat, requireSupportedEffort(model, EFFORT_BY_NAME[effort]));
|
||||
switch (mapped) {
|
||||
case "none":
|
||||
case "minimal":
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
case "xhigh":
|
||||
case "max":
|
||||
return mapped;
|
||||
default:
|
||||
throw new Error(
|
||||
`Effort map for ${model.provider}/${model.id} produced invalid Codex reasoning effort "${mapped}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReasoningConfig(
|
||||
model: Model<"openai-codex-responses">,
|
||||
effort: NonNullable<CodexRequestOptions["reasoningEffort"]>,
|
||||
options: CodexRequestOptions,
|
||||
): ReasoningConfig {
|
||||
const config: ReasoningConfig = {
|
||||
effort:
|
||||
options.reasoningEffort === "none" ? "none" : requireSupportedEffort(model, options.reasoningEffort as Effort),
|
||||
effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
|
||||
};
|
||||
// `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids
|
||||
// (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
|
||||
@@ -216,7 +262,7 @@ function stripImageDetails(input: InputItem[]): void {
|
||||
|
||||
export async function transformRequestBody(
|
||||
body: RequestBody,
|
||||
model: Model<Api>,
|
||||
model: Model<"openai-codex-responses">,
|
||||
options: CodexRequestOptions = {},
|
||||
prompt?: { developerMessages: string[] },
|
||||
): Promise<RequestBody> {
|
||||
@@ -300,7 +346,7 @@ export async function transformRequestBody(
|
||||
}
|
||||
|
||||
if (options.reasoningEffort !== undefined) {
|
||||
const reasoningConfig = getReasoningConfig(model, options);
|
||||
const reasoningConfig = getReasoningConfig(model, options.reasoningEffort, options);
|
||||
body.reasoning = {
|
||||
...body.reasoning,
|
||||
...reasoningConfig,
|
||||
@@ -323,6 +369,12 @@ export async function transformRequestBody(
|
||||
} else {
|
||||
delete body.reasoning;
|
||||
}
|
||||
// Catalog pro aliases (`gpt-5.6-*-pro`): applied after the effort branch so
|
||||
// the mode is sent even when no effort is set (the branch above deletes
|
||||
// `body.reasoning` in that case) — mode and effort are independent fields.
|
||||
if (model.reasoningMode) {
|
||||
body.reasoning = { ...body.reasoning, mode: model.reasoningMode };
|
||||
}
|
||||
|
||||
body.text = {
|
||||
...body.text,
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
createInitialResponsesAssistantMessage,
|
||||
createOpenAIStrictToolsState,
|
||||
disableStrictToolsForScope,
|
||||
getOpenAIPromptCacheKey,
|
||||
getOpenAIStrictToolsScope,
|
||||
isCompiledGrammarTooLargeStrictError,
|
||||
isOpenRouterAnthropicModel,
|
||||
@@ -616,6 +617,7 @@ const streamOpenAICompletionsOnce = (
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.initiatorOverride,
|
||||
getOpenAIPromptCacheKey(options),
|
||||
);
|
||||
const premiumRequestsTotal = copilotPremiumRequests;
|
||||
let appliedStrictTools = false;
|
||||
@@ -1359,6 +1361,7 @@ function createRequestSetup(
|
||||
apiKey?: string,
|
||||
extraHeaders?: Record<string, string>,
|
||||
initiatorOverride?: MessageAttribution,
|
||||
promptCacheSessionId?: string,
|
||||
): OpenAIRequestSetup & { baseUrl: string } {
|
||||
const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21";
|
||||
const deploymentName = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id) ?? model.id;
|
||||
@@ -1366,6 +1369,7 @@ function createRequestSetup(
|
||||
apiKey,
|
||||
extraHeaders,
|
||||
initiatorOverride,
|
||||
promptCacheSessionId,
|
||||
messages: context.messages,
|
||||
defaultBaseUrl: "https://api.openai.com/v1",
|
||||
// Provider auth/header overlay: Kimi-code hosts require shared client
|
||||
@@ -1413,7 +1417,11 @@ function buildParams(
|
||||
context: Context,
|
||||
options: OpenAICompletionsOptions | undefined,
|
||||
toolStrictModeOverride?: ToolStrictModeOverride,
|
||||
): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode; strictToolsApplied: boolean } {
|
||||
): {
|
||||
params: OpenAICompletionsParams;
|
||||
toolStrictMode: AppliedToolStrictMode;
|
||||
strictToolsApplied: boolean;
|
||||
} {
|
||||
const initialPolicy = resolveOpenAICompatForRequest(model, options);
|
||||
const initialCompat = initialPolicy.compat as ResolvedOpenAICompat;
|
||||
|
||||
|
||||
@@ -6318,6 +6318,13 @@ export interface Reasoning {
|
||||
* - `xhigh` is supported for all models after `gpt-5.1-codex-max`.
|
||||
*/
|
||||
effort?: ReasoningEffort | null;
|
||||
/**
|
||||
* **gpt-5.6 and later models only**
|
||||
*
|
||||
* Reasoning serving mode. `pro` routes the request to the pro reasoning
|
||||
* path (more compute per response); omit for the standard path.
|
||||
*/
|
||||
mode?: "pro" | null;
|
||||
/**
|
||||
* @deprecated **Deprecated:** use `summary` instead.
|
||||
*
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
createInitialResponsesAssistantMessage,
|
||||
createOpenAIStrictToolsState,
|
||||
disableStrictToolsForScope,
|
||||
getOpenAIResponsesPromptCacheKey,
|
||||
getOpenAIPromptCacheKey,
|
||||
getOpenAIResponsesRoutingSessionId,
|
||||
getOpenAIStrictToolsScope,
|
||||
getOpenRouterResponsesSessionId,
|
||||
@@ -390,7 +390,7 @@ const streamOpenAIResponsesOnce = (
|
||||
// stable prompt-cache key independently. Side-channel calls use this to
|
||||
// avoid perturbing provider conversation state without cold-starting the cache.
|
||||
const routingSessionId = getOpenAIResponsesRoutingSessionId(options);
|
||||
const promptCacheSessionId = getOpenAIResponsesPromptCacheKey(options);
|
||||
const promptCacheSessionId = getOpenAIPromptCacheKey(options);
|
||||
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
||||
const { headers, copilotPremiumRequests, baseUrl } = resolveOpenAIRequestSetup(model, {
|
||||
apiKey,
|
||||
@@ -818,7 +818,7 @@ export function buildParams(
|
||||
}
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||
const promptCacheKey = getOpenAIResponsesPromptCacheKey(options);
|
||||
const promptCacheKey = getOpenAIPromptCacheKey(options);
|
||||
const modelId = applyWireModelIdTransform(
|
||||
model.requestModelId ?? model.id,
|
||||
model.compat.wireModelIdMode,
|
||||
@@ -904,6 +904,13 @@ export function buildParams(
|
||||
model.thinking?.effortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
|
||||
effort,
|
||||
});
|
||||
// Catalog pro aliases (`gpt-5.6-*-pro`): merge AFTER the compat policy so the
|
||||
// mode survives every policy branch (disabled/omitted effort included) while
|
||||
// keeping whatever effort/summary the policy produced — mode and effort are
|
||||
// independent wire fields.
|
||||
if (model.reasoningMode) {
|
||||
params.reasoning = { ...params.reasoning, mode: model.reasoningMode };
|
||||
}
|
||||
|
||||
applyOpenAIGatewayRouting(params, model.compat);
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ export interface OpenAIRequestSetupModel extends OpenAIModelIdentity {
|
||||
compat?: Pick<ResolvedOpenAISharedCompat, "promptCacheSessionHeader">;
|
||||
}
|
||||
|
||||
export interface OpenAIResponsesCacheOptions {
|
||||
/** Cache identity controls shared by OpenAI-family transports. */
|
||||
export interface OpenAICacheOptions {
|
||||
cacheRetention?: CacheRetention;
|
||||
sessionId?: string;
|
||||
promptCacheKey?: string;
|
||||
@@ -175,6 +176,14 @@ function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setHeaderIfAbsent(headers: Record<string, string>, name: string, value: string): void {
|
||||
const normalizedName = name.toLowerCase();
|
||||
for (const existingName in headers) {
|
||||
if (existingName.toLowerCase() === normalizedName) return;
|
||||
}
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
export function resolveOpenAIRequestSetup(
|
||||
model: OpenAIRequestSetupModel,
|
||||
options: OpenAIRequestSetupOptions,
|
||||
@@ -257,11 +266,11 @@ export function resolveOpenAIRequestSetup(
|
||||
}
|
||||
|
||||
if (options.openAISessionId && model.provider === "openai") {
|
||||
headers.session_id ??= options.openAISessionId;
|
||||
headers["x-client-request-id"] ??= options.openAISessionId;
|
||||
setHeaderIfAbsent(headers, "session_id", options.openAISessionId);
|
||||
setHeaderIfAbsent(headers, "x-client-request-id", options.openAISessionId);
|
||||
}
|
||||
if (options.promptCacheSessionId && model.compat?.promptCacheSessionHeader) {
|
||||
headers[model.compat.promptCacheSessionHeader] ??= options.promptCacheSessionId;
|
||||
setHeaderIfAbsent(headers, model.compat.promptCacheSessionHeader, options.promptCacheSessionId);
|
||||
}
|
||||
|
||||
if (options.defaultBaseUrl !== undefined) {
|
||||
@@ -368,7 +377,8 @@ export function calculateOpenAIUsageAccounting(accounting: OpenAIUsageAccounting
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpenAIResponsesPromptCacheKey(sessionId: string | undefined): string | undefined {
|
||||
/** Normalize a cache identity to the wire limit accepted by OpenAI-family providers. */
|
||||
export function normalizeOpenAIPromptCacheKey(sessionId: string | undefined): string | undefined {
|
||||
return normalizeOpenAIStableId(sessionId, 64, "pc_");
|
||||
}
|
||||
|
||||
@@ -376,20 +386,21 @@ export function normalizeOpenRouterResponsesSessionId(sessionId: string | undefi
|
||||
return normalizeOpenAIStableId(sessionId, 256, "session_");
|
||||
}
|
||||
|
||||
export function getOpenAIResponsesPromptCacheKey(options: OpenAIResponsesCacheOptions | undefined): string | undefined {
|
||||
/** Resolve a prompt-cache identity, falling back to the provider session unless caching is disabled. */
|
||||
export function getOpenAIPromptCacheKey(options: OpenAICacheOptions | undefined): string | undefined {
|
||||
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
|
||||
return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
||||
return normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
||||
}
|
||||
|
||||
export function getOpenAIResponsesRoutingSessionId(
|
||||
options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
|
||||
options: Pick<OpenAICacheOptions, "cacheRetention" | "sessionId"> | undefined,
|
||||
): string | undefined {
|
||||
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
|
||||
return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
||||
return normalizeOpenAIPromptCacheKey(options?.sessionId);
|
||||
}
|
||||
|
||||
export function getOpenRouterResponsesSessionId(
|
||||
options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
|
||||
options: Pick<OpenAICacheOptions, "cacheRetention" | "sessionId"> | undefined,
|
||||
): string | undefined {
|
||||
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
|
||||
return normalizeOpenRouterResponsesSessionId(options?.sessionId);
|
||||
@@ -695,13 +706,19 @@ export interface OpenAICompatPolicy {
|
||||
};
|
||||
}
|
||||
|
||||
function mapOpenAIReasoningEffort(
|
||||
/**
|
||||
* Map a user-facing effort to the provider wire value: explicit compat
|
||||
* override first, then the model's baked `thinking.effortMap`, else identity.
|
||||
* Shared by the chat-completions/Responses policy resolver and the Codex
|
||||
* request transformer.
|
||||
*/
|
||||
export function mapOpenAIReasoningEffort(
|
||||
model: Pick<Model, "thinking">,
|
||||
compat: OpenAICompatPolicyCompat,
|
||||
compat: { reasoningEffortMap?: Partial<Record<Effort, string>> } | undefined,
|
||||
effort: string,
|
||||
): string {
|
||||
const level = effort as Effort;
|
||||
return compat.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
|
||||
return compat?.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
|
||||
}
|
||||
|
||||
function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMode): boolean {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { isXAIAccessTokenExpiring, refreshXAIOAuthToken, validateXAIEndpoint, XAIOAuthFlow } from "../xai-oauth";
|
||||
import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -11,6 +11,76 @@ function jwtWithExp(exp: number): string {
|
||||
return `${header}.${payload}.sig`;
|
||||
}
|
||||
|
||||
const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
||||
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
|
||||
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
|
||||
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
||||
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
||||
|
||||
const DEVICE_AUTHORIZATION = {
|
||||
device_code: "device-code-123",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://auth.x.ai/activate",
|
||||
verification_uri_complete: "https://auth.x.ai/activate?user_code=ABCD-EFGH",
|
||||
expires_in: 600,
|
||||
interval: 1,
|
||||
};
|
||||
|
||||
type RecordedRequest = {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
body: unknown;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
|
||||
const requests: RecordedRequest[] = [];
|
||||
let tokenResponseIndex = 0;
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
||||
requests.push({ url, init });
|
||||
|
||||
if (url === DISCOVERY_URL) {
|
||||
return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
|
||||
}
|
||||
if (url === DEVICE_CODE_URL) {
|
||||
return jsonResponse(DEVICE_AUTHORIZATION);
|
||||
}
|
||||
if (url === TOKEN_ENDPOINT) {
|
||||
const tokenResponse = tokenResponses[tokenResponseIndex];
|
||||
tokenResponseIndex += 1;
|
||||
if (!tokenResponse) {
|
||||
throw new Error(`Unexpected xAI token poll ${tokenResponseIndex}`);
|
||||
}
|
||||
return jsonResponse(tokenResponse.body, tokenResponse.status);
|
||||
}
|
||||
throw new Error(`Unexpected xAI OAuth request: ${url}`);
|
||||
});
|
||||
|
||||
return {
|
||||
fetchMock: fetchMock as unknown as typeof fetch,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
function requestForm(request: RecordedRequest | undefined): URLSearchParams {
|
||||
const body = request?.init?.body;
|
||||
if (!(body instanceof URLSearchParams)) {
|
||||
throw new Error("Expected an application/x-www-form-urlencoded request body");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
describe("isXAIAccessTokenExpiring", () => {
|
||||
it("returns false for an empty string", () => {
|
||||
expect(isXAIAccessTokenExpiring("")).toBe(false);
|
||||
@@ -63,97 +133,138 @@ describe("refreshXAIOAuthToken", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("XAIOAuthFlow", () => {
|
||||
it("pins the redirect URI to xAI's allowlisted loopback port", () => {
|
||||
const flow = new XAIOAuthFlow({});
|
||||
|
||||
expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
|
||||
});
|
||||
|
||||
it("uses pasted-code login without starting a callback server", async () => {
|
||||
const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
|
||||
throw new Error("callback server should not start");
|
||||
});
|
||||
let authUrl = "";
|
||||
let tokenRequestBody = "";
|
||||
const progress: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
||||
if (url.includes("/.well-known/openid-configuration")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth/authorize",
|
||||
token_endpoint: "https://auth.x.ai/oauth/token",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
describe("loginXAIOAuth", () => {
|
||||
it("performs the RFC 8628 device flow and returns the issued credentials", async () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { fetchMock, requests } = createDeviceFlowFetch([
|
||||
{
|
||||
body: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
},
|
||||
]);
|
||||
const authEvents: Array<{ url: string; instructions?: string }> = [];
|
||||
const progress: string[] = [];
|
||||
const onAuth = vi.fn((info: { url: string; instructions?: string }) => {
|
||||
authEvents.push(info);
|
||||
});
|
||||
const onProgress = vi.fn((message: string) => {
|
||||
progress.push(message);
|
||||
});
|
||||
const onManualCodeInput = vi.fn(async () => {
|
||||
throw new Error("device authorization must not request a pasted code");
|
||||
});
|
||||
|
||||
const flow = new XAIOAuthFlow({
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
onAuth: info => {
|
||||
authUrl = info.url;
|
||||
},
|
||||
onManualCodeInput: async () => {
|
||||
const parsed = new URL(authUrl);
|
||||
const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
|
||||
const state = parsed.searchParams.get("state") ?? "";
|
||||
return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
|
||||
},
|
||||
onProgress: message => progress.push(message),
|
||||
const credentials = await loginXAIOAuth({
|
||||
fetch: fetchMock,
|
||||
onAuth,
|
||||
onProgress,
|
||||
onManualCodeInput,
|
||||
});
|
||||
|
||||
const credentials = await flow.login();
|
||||
const authorizeUrl = new URL(authUrl);
|
||||
const tokenParams = new URLSearchParams(tokenRequestBody);
|
||||
expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
|
||||
|
||||
expect(serveSpy).not.toHaveBeenCalled();
|
||||
expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
|
||||
expect(progress).toContain("Waiting for pasted authorization code...");
|
||||
expect(tokenParams.get("code")).toBe("code-xyz");
|
||||
expect(credentials.access).toBe("access-token");
|
||||
expect(credentials.refresh).toBe("refresh-token");
|
||||
const discoveryRequest = requests[0];
|
||||
expect(discoveryRequest?.init?.method).toBe("GET");
|
||||
expect(new Headers(discoveryRequest?.init?.headers).get("Accept")).toBe("application/json");
|
||||
|
||||
const deviceRequest = requests[1];
|
||||
expect(deviceRequest?.init?.method).toBe("POST");
|
||||
const deviceHeaders = new Headers(deviceRequest?.init?.headers);
|
||||
expect(deviceHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(deviceHeaders.get("Accept")).toBe("application/json");
|
||||
const deviceForm = requestForm(deviceRequest);
|
||||
expect([...deviceForm.keys()].sort()).toEqual(["client_id", "scope"]);
|
||||
expect(Object.fromEntries(deviceForm)).toEqual({
|
||||
client_id: CLIENT_ID,
|
||||
scope: SCOPE,
|
||||
});
|
||||
|
||||
const tokenRequest = requests[2];
|
||||
expect(tokenRequest?.init?.method).toBe("POST");
|
||||
const tokenHeaders = new Headers(tokenRequest?.init?.headers);
|
||||
expect(tokenHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
|
||||
expect(tokenHeaders.get("Accept")).toBe("application/json");
|
||||
const tokenForm = requestForm(tokenRequest);
|
||||
expect([...tokenForm.keys()].sort()).toEqual(["client_id", "device_code", "grant_type"]);
|
||||
expect(Object.fromEntries(tokenForm)).toEqual({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: DEVICE_AUTHORIZATION.device_code,
|
||||
});
|
||||
|
||||
expect(authEvents).toEqual([
|
||||
{
|
||||
url: DEVICE_AUTHORIZATION.verification_uri_complete,
|
||||
instructions: `Enter code: ${DEVICE_AUTHORIZATION.user_code}`,
|
||||
},
|
||||
]);
|
||||
expect(authEvents[0]?.instructions).not.toMatch(/hermes/i);
|
||||
expect(onManualCodeInput).not.toHaveBeenCalled();
|
||||
expect(progress).toEqual(["Waiting for xAI device authorization..."]);
|
||||
expect(credentials).toEqual({
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: now + 3_300_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("XAIOAuthFlow.exchangeToken", () => {
|
||||
it("rejects when the token-exchange response is missing access_token", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/.well-known/openid-configuration")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth/authorize",
|
||||
token_endpoint: "https://auth.x.ai/oauth/token",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
// Token-exchange response deliberately omits `access_token` to exercise
|
||||
// the missing-token rejection path. The value of `refresh_token` here is
|
||||
// a literal test marker, not a real secret — the test verifies
|
||||
// exchangeToken throws before any token would be persisted.
|
||||
return new Response(JSON.stringify({ refresh_token: "stub-refresh-token-for-test-only" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
it("continues through authorization_pending and slow_down responses", async () => {
|
||||
const sleepSpy = vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
|
||||
const { fetchMock, requests } = createDeviceFlowFetch([
|
||||
{ status: 400, body: { error: "authorization_pending" } },
|
||||
{ status: 400, body: { error: "slow_down" } },
|
||||
{
|
||||
body: {
|
||||
access_token: "eventual-access-token",
|
||||
refresh_token: "eventual-refresh-token",
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const flow = new XAIOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
|
||||
await flow.generateAuthUrl("state-abc", "http://127.0.0.1:56121/callback");
|
||||
const credentials = await loginXAIOAuth({ fetch: fetchMock });
|
||||
|
||||
await expect(flow.exchangeToken("code-xyz", "state-abc", "http://127.0.0.1:56121/callback")).rejects.toThrow(
|
||||
/access_token/,
|
||||
const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
|
||||
expect(tokenRequests).toHaveLength(3);
|
||||
expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
|
||||
{
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: DEVICE_AUTHORIZATION.device_code,
|
||||
},
|
||||
{
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: DEVICE_AUTHORIZATION.device_code,
|
||||
},
|
||||
{
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: DEVICE_AUTHORIZATION.device_code,
|
||||
},
|
||||
]);
|
||||
expect(sleepSpy.mock.calls).toEqual([[1000], [6000]]);
|
||||
expect(credentials.access).toBe("eventual-access-token");
|
||||
expect(credentials.refresh).toBe("eventual-refresh-token");
|
||||
});
|
||||
|
||||
it("rejects a token response that omits access_token", async () => {
|
||||
const { fetchMock, requests } = createDeviceFlowFetch([
|
||||
{
|
||||
body: {
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(loginXAIOAuth({ fetch: fetchMock })).rejects.toThrow(
|
||||
/xAI device-code token response missing access_token/,
|
||||
);
|
||||
expect(requests.filter(request => request.url === TOKEN_ENDPOINT)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as AIError from "../../error";
|
||||
|
||||
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
||||
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
||||
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
||||
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
||||
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
||||
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
||||
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
||||
|
||||
/** Result returned by one OAuth device-code polling attempt. */
|
||||
export type OAuthDeviceCodePollResult<T> =
|
||||
| { status: "complete"; value: T }
|
||||
| { status: "pending" }
|
||||
| { status: "slow_down" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
||||
export interface OAuthDeviceCodeFlowOptions<T> {
|
||||
/** Poll the provider once and classify the response. */
|
||||
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
||||
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
||||
intervalSeconds?: number;
|
||||
/** Provider-issued expiry window for the device code. */
|
||||
expiresInSeconds?: number;
|
||||
/** Cancels the flow with the legacy "Login cancelled" error. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (!signal) {
|
||||
await Bun.sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let timer: Timer | undefined;
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
await promise;
|
||||
}
|
||||
|
||||
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
||||
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
||||
const deadline =
|
||||
typeof options.expiresInSeconds === "number"
|
||||
? Date.now() + options.expiresInSeconds * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
let intervalMs = Math.max(
|
||||
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
||||
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
||||
);
|
||||
let slowDownResponses = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
const result = await options.poll();
|
||||
if (result.status === "complete") {
|
||||
return result.value;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
throw new AIError.OAuthError(result.message, { kind: "polling" });
|
||||
}
|
||||
if (result.status === "slow_down") {
|
||||
slowDownResponses += 1;
|
||||
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
||||
}
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
||||
}
|
||||
|
||||
throw new AIError.OAuthError(
|
||||
slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
|
||||
{ kind: "timeout" },
|
||||
);
|
||||
}
|
||||
@@ -12,99 +12,9 @@ import type {
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
|
||||
export * from "./device-code";
|
||||
export type * from "./types";
|
||||
|
||||
const DEVICE_FLOW_CANCEL_MESSAGE = "Login cancelled";
|
||||
const DEVICE_FLOW_TIMEOUT_MESSAGE = "Device flow timed out";
|
||||
const DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE =
|
||||
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
||||
const MINIMUM_DEVICE_FLOW_INTERVAL_MS = 1000;
|
||||
const DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS = 5;
|
||||
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
||||
|
||||
/** Result returned by one OAuth device-code polling attempt. */
|
||||
export type OAuthDeviceCodePollResult<T> =
|
||||
| { status: "complete"; value: T }
|
||||
| { status: "pending" }
|
||||
| { status: "slow_down" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
/** Options for polling an RFC 8628-style OAuth device-code flow. */
|
||||
export interface OAuthDeviceCodeFlowOptions<T> {
|
||||
/** Poll the provider once and classify the response. */
|
||||
poll(): OAuthDeviceCodePollResult<T> | Promise<OAuthDeviceCodePollResult<T>>;
|
||||
/** Provider-requested polling cadence; defaults to RFC 8628's five seconds. */
|
||||
intervalSeconds?: number;
|
||||
/** Provider-issued expiry window for the device code. */
|
||||
expiresInSeconds?: number;
|
||||
/** Cancels the flow with the legacy "Login cancelled" error. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function abortableDeviceFlowSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (!signal) {
|
||||
await Bun.sleep(ms);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let timer: Timer | undefined;
|
||||
const onAbort = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE));
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
await promise;
|
||||
}
|
||||
|
||||
/** Poll an OAuth device-code flow until completion, provider failure, timeout, or cancellation. */
|
||||
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodeFlowOptions<T>): Promise<T> {
|
||||
const deadline =
|
||||
typeof options.expiresInSeconds === "number"
|
||||
? Date.now() + options.expiresInSeconds * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
let intervalMs = Math.max(
|
||||
MINIMUM_DEVICE_FLOW_INTERVAL_MS,
|
||||
Math.floor((options.intervalSeconds ?? DEFAULT_DEVICE_FLOW_INTERVAL_SECONDS) * 1000),
|
||||
);
|
||||
let slowDownResponses = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
throw new AIError.LoginCancelledError(DEVICE_FLOW_CANCEL_MESSAGE);
|
||||
}
|
||||
const result = await options.poll();
|
||||
if (result.status === "complete") {
|
||||
return result.value;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
throw new AIError.OAuthError(result.message, { kind: "polling" });
|
||||
}
|
||||
if (result.status === "slow_down") {
|
||||
slowDownResponses += 1;
|
||||
intervalMs = Math.max(MINIMUM_DEVICE_FLOW_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
||||
}
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await abortableDeviceFlowSleep(Math.min(intervalMs, remainingMs), options.signal);
|
||||
}
|
||||
|
||||
throw new AIError.OAuthError(
|
||||
slowDownResponses > 0 ? DEVICE_FLOW_SLOW_DOWN_TIMEOUT_MESSAGE : DEVICE_FLOW_TIMEOUT_MESSAGE,
|
||||
{ kind: "timeout" },
|
||||
);
|
||||
}
|
||||
|
||||
const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
|
||||
provider => provider.login && provider.showInLoginList !== false,
|
||||
).map(provider => ({
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
// Ported from NousResearch/hermes-agent (MIT) — hermes_cli/auth.py xAI sections (L93-111, L2979-3160, L5286-5469).
|
||||
// Device authorization and token refresh adapted from NousResearch/hermes-agent (MIT).
|
||||
|
||||
/**
|
||||
* xAI Grok (SuperGrok or X Premium+) OAuth flow.
|
||||
* xAI Grok OAuth device authorization flow.
|
||||
*
|
||||
* Manual-code PKCE flow using `127.0.0.1:56121/callback` as the allowlisted
|
||||
* redirect URI. One token unlocks Grok-4.x
|
||||
* chat, Grok Imagine image generation, and Grok Voice TTS via subsequent
|
||||
* commits. Endpoint discovery is hardened against MITM via
|
||||
* {@link validateXAIEndpoint}: any non-HTTPS or non-`x.ai`/`*.x.ai` host is
|
||||
* rejected on every call site, not just the first.
|
||||
* Requests an RFC 8628 device code, opens xAI's verification page, and polls
|
||||
* the discovered token endpoint until the user approves the login.
|
||||
*/
|
||||
|
||||
import * as AIError from "../../error";
|
||||
import type { FetchImpl } from "../../types";
|
||||
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
||||
import { generatePKCE } from "./pkce";
|
||||
import { type OAuthDeviceCodePollResult, pollOAuthDeviceCodeFlow } from "./device-code";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
// Hermes hermes_cli/auth.py L93-111
|
||||
const XAI_OAUTH_ISSUER = "https://auth.x.ai";
|
||||
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
|
||||
const XAI_OAUTH_DEVICE_CODE_URL = `${XAI_OAUTH_ISSUER}/oauth2/device/code`;
|
||||
const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
||||
const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
||||
const XAI_OAUTH_REDIRECT_HOST = "127.0.0.1";
|
||||
const XAI_OAUTH_REDIRECT_PORT = 56121;
|
||||
const XAI_OAUTH_REDIRECT_PATH = "/callback";
|
||||
const XAI_OAUTH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth";
|
||||
|
||||
// Mirrors the 5-min skew used by anthropic.ts:160 — keeps every provider on the
|
||||
// same conservative client-side expiry window.
|
||||
@@ -35,18 +26,27 @@ const DISCOVERY_TIMEOUT_MS = 15_000;
|
||||
const TOKEN_REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
interface XAIOAuthDiscovery {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
interface XAIDeviceAuthorization {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUriComplete: string;
|
||||
expiresInSeconds: number;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an xAI OIDC discovery endpoint against scheme + host.
|
||||
* Validate an xAI OIDC endpoint against its scheme and host.
|
||||
*
|
||||
* Hermes `_xai_validate_oauth_endpoint` L2997-3035. The discovery response is
|
||||
* long-lived and cached in {@link OAuthCredentials}; a single MITM during
|
||||
* initial login could substitute a malicious `token_endpoint` that would then
|
||||
* receive every future refresh_token. Rejecting non-HTTPS or non-`x.ai` /
|
||||
* `*.x.ai` hosts pins the cached endpoint to the xAI auth origin.
|
||||
* The discovery response is long-lived and its token endpoint receives every
|
||||
* future refresh token. Rejecting non-HTTPS or non-`x.ai` / `*.x.ai` hosts
|
||||
* pins that endpoint to the xAI auth origin.
|
||||
*
|
||||
* @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
|
||||
* either scheme or host validation.
|
||||
@@ -68,11 +68,7 @@ export function validateXAIEndpoint(url: string, field: string): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch xAI's OIDC discovery document and validate both endpoints.
|
||||
*
|
||||
* Hermes `_xai_oauth_discovery` L3038-3084.
|
||||
*/
|
||||
/** Fetch xAI's OIDC discovery document and validate the token endpoint. */
|
||||
async function xaiOAuthDiscovery(
|
||||
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
||||
fetchOverride?: FetchImpl,
|
||||
@@ -111,37 +107,29 @@ async function xaiOAuthDiscovery(
|
||||
{ kind: "validation", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
if (!isRecord(payload)) {
|
||||
throw new AIError.OAuthError("xAI OIDC discovery response was not a JSON object.", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const authorizationEndpoint =
|
||||
typeof obj.authorization_endpoint === "string" ? obj.authorization_endpoint.trim() : "";
|
||||
const tokenEndpoint = typeof obj.token_endpoint === "string" ? obj.token_endpoint.trim() : "";
|
||||
if (!authorizationEndpoint || !tokenEndpoint) {
|
||||
throw new AIError.OAuthError("xAI OIDC discovery response was missing required endpoints.", {
|
||||
const tokenEndpoint = typeof payload.token_endpoint === "string" ? payload.token_endpoint.trim() : "";
|
||||
if (!tokenEndpoint) {
|
||||
throw new AIError.OAuthError("xAI OIDC discovery response was missing token_endpoint.", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
validateXAIEndpoint(authorizationEndpoint, "authorization_endpoint");
|
||||
validateXAIEndpoint(tokenEndpoint, "token_endpoint");
|
||||
return {
|
||||
authorization_endpoint: authorizationEndpoint,
|
||||
token_endpoint: tokenEndpoint,
|
||||
};
|
||||
return { token_endpoint: tokenEndpoint };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a JWT access token is at or past its `exp` claim (with an
|
||||
* optional refresh-skew margin).
|
||||
*
|
||||
* Hermes `_xai_access_token_is_expiring` L2979-2994. Returns `false` for any
|
||||
* malformed input — this is a refresh-trigger check, not a validation, so
|
||||
* non-JWTs ("no token in cache") must NOT trigger a spurious refresh.
|
||||
* Returns `false` for malformed input because this is a refresh-trigger check,
|
||||
* not token validation.
|
||||
*/
|
||||
export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0): boolean {
|
||||
try {
|
||||
@@ -151,7 +139,8 @@ export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0):
|
||||
const payloadPart = parts[1];
|
||||
if (!payloadPart) return false;
|
||||
const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
|
||||
const payload = JSON.parse(decoded) as { exp?: unknown };
|
||||
const payload: unknown = JSON.parse(decoded);
|
||||
if (!isRecord(payload)) return false;
|
||||
const exp = payload.exp;
|
||||
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -162,161 +151,232 @@ export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0):
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildXAIAuthorizeUrlOptions {
|
||||
authorizationEndpoint: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the xAI authorization URL.
|
||||
*
|
||||
* Hermes `_xai_oauth_build_authorize_url` L5286-5312. `plan=generic` opts the
|
||||
* consent screen into xAI's generic OAuth plan tier; without it,
|
||||
* `accounts.x.ai` rejects loopback OAuth from non-allowlisted clients.
|
||||
* `referrer=oh-my-pi` lets xAI attribute oh-my-pi-originated logins in their
|
||||
* OAuth server logs (Hermes uses `referrer=hermes-agent`; oh-my-pi mirrors the
|
||||
* pattern with its own attribution string).
|
||||
*/
|
||||
function buildXAIAuthorizeUrl(opts: BuildXAIAuthorizeUrlOptions): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: XAI_OAUTH_CLIENT_ID,
|
||||
redirect_uri: opts.redirectUri,
|
||||
scope: XAI_OAUTH_SCOPE,
|
||||
code_challenge: opts.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state: opts.state,
|
||||
nonce: opts.nonce,
|
||||
plan: "generic",
|
||||
referrer: "oh-my-pi",
|
||||
});
|
||||
return `${opts.authorizationEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI Grok OAuth code flow (Hermes `_xai_oauth_loopback_login` L5315-5469).
|
||||
*/
|
||||
export class XAIOAuthFlow extends OAuthCallbackFlow {
|
||||
#verifier: string = "";
|
||||
#fetch: FetchImpl;
|
||||
|
||||
constructor(ctrl: OAuthController) {
|
||||
super(ctrl, {
|
||||
preferredPort: XAI_OAUTH_REDIRECT_PORT,
|
||||
callbackPath: XAI_OAUTH_REDIRECT_PATH,
|
||||
callbackHostname: XAI_OAUTH_REDIRECT_HOST,
|
||||
redirectUri: `http://${XAI_OAUTH_REDIRECT_HOST}:${XAI_OAUTH_REDIRECT_PORT}${XAI_OAUTH_REDIRECT_PATH}`,
|
||||
manualInputOnly: true,
|
||||
} satisfies OAuthCallbackFlowOptions);
|
||||
this.#fetch = ctrl.fetch ?? fetch;
|
||||
function parseXAIDeviceAuthorization(payload: unknown): XAIDeviceAuthorization {
|
||||
if (!isRecord(payload)) {
|
||||
throw new AIError.OAuthError("xAI device-code response was not a JSON object.", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
||||
const pkce = await generatePKCE();
|
||||
this.#verifier = pkce.verifier;
|
||||
const nonce = crypto.randomUUID().replace(/-/g, "");
|
||||
|
||||
const discovery = await xaiOAuthDiscovery(DISCOVERY_TIMEOUT_MS, this.#fetch);
|
||||
const url = buildXAIAuthorizeUrl({
|
||||
authorizationEndpoint: discovery.authorization_endpoint,
|
||||
redirectUri,
|
||||
codeChallenge: pkce.challenge,
|
||||
state,
|
||||
nonce,
|
||||
const deviceCode = typeof payload.device_code === "string" ? payload.device_code.trim() : "";
|
||||
const userCode = typeof payload.user_code === "string" ? payload.user_code.trim() : "";
|
||||
const verificationUri = typeof payload.verification_uri === "string" ? payload.verification_uri.trim() : "";
|
||||
const verificationUriComplete =
|
||||
typeof payload.verification_uri_complete === "string" ? payload.verification_uri_complete.trim() : "";
|
||||
const expiresInSeconds = payload.expires_in;
|
||||
const intervalSeconds = payload.interval;
|
||||
if (
|
||||
!deviceCode ||
|
||||
!userCode ||
|
||||
!verificationUri ||
|
||||
!verificationUriComplete ||
|
||||
typeof expiresInSeconds !== "number" ||
|
||||
!Number.isFinite(expiresInSeconds) ||
|
||||
expiresInSeconds <= 0 ||
|
||||
typeof intervalSeconds !== "number" ||
|
||||
!Number.isFinite(intervalSeconds) ||
|
||||
intervalSeconds <= 0
|
||||
) {
|
||||
throw new AIError.OAuthError("xAI device-code response missing or invalid required fields.", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions: `Complete login in your browser for xAI Grok (SuperGrok or X Premium+). Docs: ${XAI_OAUTH_DOCS_URL}`,
|
||||
};
|
||||
}
|
||||
|
||||
async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
||||
const discovery = await xaiOAuthDiscovery(DISCOVERY_TIMEOUT_MS, this.#fetch);
|
||||
const tokenEndpoint = validateXAIEndpoint(discovery.token_endpoint, "token_endpoint");
|
||||
validateXAIEndpoint(verificationUri, "verification_uri");
|
||||
validateXAIEndpoint(verificationUriComplete, "verification_uri_complete");
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUriComplete,
|
||||
expiresInSeconds,
|
||||
intervalSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: XAI_OAUTH_CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: this.#verifier,
|
||||
function parseXAITokenResponse(payload: unknown, label: string, refreshTokenFallback?: string): OAuthCredentials {
|
||||
if (!isRecord(payload)) {
|
||||
throw new AIError.OAuthError(`${label} was not a JSON object`, {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
const accessToken = typeof payload.access_token === "string" ? payload.access_token : "";
|
||||
const responseRefreshToken = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
|
||||
const refreshToken = responseRefreshToken || refreshTokenFallback || "";
|
||||
const expiresInSeconds = payload.expires_in;
|
||||
if (!accessToken) {
|
||||
throw new AIError.OAuthError(`${label} missing access_token`, {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
if (!refreshToken) {
|
||||
throw new AIError.OAuthError(`${label} missing refresh_token`, {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
if (typeof expiresInSeconds !== "number" || !Number.isFinite(expiresInSeconds)) {
|
||||
throw new AIError.OAuthError(`${label} missing expires_in`, {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
return {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: Date.now() + expiresInSeconds * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.#fetch(tokenEndpoint, {
|
||||
async function requestXAIDeviceAuthorization(
|
||||
fetchImpl: FetchImpl,
|
||||
signal?: AbortSignal,
|
||||
): Promise<XAIDeviceAuthorization> {
|
||||
let response: Response;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
||||
response = await fetchImpl(XAI_OAUTH_DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
|
||||
body: new URLSearchParams({
|
||||
client_id: XAI_OAUTH_CLIENT_ID,
|
||||
scope: XAI_OAUTH_SCOPE,
|
||||
}),
|
||||
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await response.text()).trim();
|
||||
} catch {
|
||||
// Ignore body-read failures; the status code is the diagnostic.
|
||||
}
|
||||
throw new AIError.OAuthError(`xAI token exchange failed: ${response.status}${detail ? ` ${detail}` : ""}`, {
|
||||
kind: "token-exchange",
|
||||
provider: "xai",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
let tokenData: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
try {
|
||||
tokenData = (await response.json()) as typeof tokenData;
|
||||
} catch (error) {
|
||||
throw new AIError.OAuthError(
|
||||
`xAI token exchange returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ kind: "validation", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof tokenData.access_token !== "string" || !tokenData.access_token) {
|
||||
throw new AIError.OAuthError("xAI token exchange response missing access_token", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
if (typeof tokenData.refresh_token !== "string" || !tokenData.refresh_token) {
|
||||
throw new AIError.OAuthError("xAI token exchange response missing refresh_token", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
if (typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in)) {
|
||||
throw new AIError.OAuthError("xAI token exchange response missing expires_in", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: tokenData.access_token,
|
||||
refresh: tokenData.refresh_token,
|
||||
expires: Date.now() + tokenData.expires_in * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new AIError.LoginCancelledError();
|
||||
throw new AIError.OAuthError(
|
||||
`xAI device-code request failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ kind: "device-auth", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await response.text()).trim();
|
||||
} catch {
|
||||
// Ignore body-read failures; the status code is the diagnostic.
|
||||
}
|
||||
throw new AIError.OAuthError(`xAI device-code request failed: ${response.status}${detail ? ` ${detail}` : ""}`, {
|
||||
kind: "device-auth",
|
||||
provider: "xai",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new AIError.OAuthError(
|
||||
`xAI device-code response returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ kind: "validation", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
return parseXAIDeviceAuthorization(payload);
|
||||
}
|
||||
|
||||
async function pollXAIDeviceToken(
|
||||
tokenEndpoint: string,
|
||||
deviceCode: string,
|
||||
fetchImpl: FetchImpl,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OAuthDeviceCodePollResult<OAuthCredentials>> {
|
||||
let response: Response;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
|
||||
response = await fetchImpl(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: XAI_OAUTH_CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
}),
|
||||
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new AIError.LoginCancelledError();
|
||||
throw new AIError.OAuthError(
|
||||
`xAI device-code token polling failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ kind: "polling", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new AIError.OAuthError(
|
||||
`xAI device-code token polling returned invalid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ kind: "polling", provider: "xai", status: response.status, cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
status: "complete",
|
||||
value: parseXAITokenResponse(payload, "xAI device-code token response"),
|
||||
};
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new AIError.OAuthError(`xAI device-code token polling failed: ${response.status}`, {
|
||||
kind: "polling",
|
||||
provider: "xai",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const errorCode = typeof payload.error === "string" ? payload.error : "";
|
||||
if (errorCode === "authorization_pending") return { status: "pending" };
|
||||
if (errorCode === "slow_down") return { status: "slow_down" };
|
||||
|
||||
const errorDescription = typeof payload.error_description === "string" ? payload.error_description : "";
|
||||
const detail = errorDescription || errorCode || String(response.status);
|
||||
throw new AIError.OAuthError(`xAI device-code token polling failed: ${detail}`, {
|
||||
kind: "polling",
|
||||
provider: "xai",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
/** Log in to xAI Grok with the RFC 8628 device authorization grant. */
|
||||
export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
|
||||
return new XAIOAuthFlow(ctrl).login();
|
||||
const fetchImpl = ctrl.fetch ?? fetch;
|
||||
const discovery = await xaiOAuthDiscovery(DISCOVERY_TIMEOUT_MS, fetchImpl);
|
||||
const device = await requestXAIDeviceAuthorization(fetchImpl, ctrl.signal);
|
||||
ctrl.onAuth?.({
|
||||
url: device.verificationUriComplete,
|
||||
instructions: `Enter code: ${device.userCode}`,
|
||||
});
|
||||
ctrl.onProgress?.("Waiting for xAI device authorization...");
|
||||
|
||||
return pollOAuthDeviceCodeFlow({
|
||||
poll: () => pollXAIDeviceToken(discovery.token_endpoint, device.deviceCode, fetchImpl, ctrl.signal),
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an xAI OAuth access token using a stored refresh_token.
|
||||
*
|
||||
* Hermes `refresh_xai_oauth_pure` L3087-3160. Re-runs OIDC discovery and
|
||||
* re-validates the cached `token_endpoint` on the refresh hot path so a
|
||||
* cached-but-poisoned endpoint cannot silently leak a refresh_token.
|
||||
* Re-runs OIDC discovery and re-validates the token endpoint before sending
|
||||
* the stored refresh token.
|
||||
*/
|
||||
export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?: FetchImpl): Promise<OAuthCredentials> {
|
||||
const fetchImpl = fetchOverride ?? fetch;
|
||||
@@ -357,34 +417,14 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
|
||||
});
|
||||
}
|
||||
|
||||
let data: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
let payload: unknown;
|
||||
try {
|
||||
data = (await response.json()) as typeof data;
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new AIError.OAuthError(
|
||||
`xAI token refresh returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ kind: "validation", provider: "xai", cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.access_token !== "string" || !data.access_token) {
|
||||
throw new AIError.OAuthError("xAI token refresh response missing access_token", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) {
|
||||
throw new AIError.OAuthError("xAI token refresh response missing expires_in", {
|
||||
kind: "validation",
|
||||
provider: "xai",
|
||||
});
|
||||
}
|
||||
|
||||
const newRefresh = typeof data.refresh_token === "string" && data.refresh_token ? data.refresh_token : refreshToken;
|
||||
|
||||
return {
|
||||
access: data.access_token,
|
||||
refresh: newRefresh,
|
||||
expires: Date.now() + data.expires_in * 1000 - ACCESS_TOKEN_CLIENT_SKEW_MS,
|
||||
};
|
||||
return parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
|
||||
}
|
||||
|
||||
@@ -14,5 +14,4 @@ export const xaiOauthProvider = {
|
||||
const { refreshXAIOAuthToken } = await import("./oauth/xai-oauth");
|
||||
return refreshXAIOAuthToken(credentials.refresh);
|
||||
},
|
||||
pasteCodeFlow: true,
|
||||
} as const satisfies ProviderDefinition;
|
||||
|
||||
@@ -388,9 +388,9 @@ export interface StreamOptions {
|
||||
*/
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Optional prompt-cache identity. When set, OpenAI Responses-compatible
|
||||
* providers use this for `prompt_cache_key` while keeping `sessionId` for
|
||||
* provider routing / conversation headers.
|
||||
* Optional prompt-cache identity. OpenAI-family providers use this for
|
||||
* `prompt_cache_key` payloads and cache-affinity headers such as
|
||||
* `x-grok-conv-id`; when omitted, they fall back to `sessionId`.
|
||||
*/
|
||||
promptCacheKey?: string;
|
||||
/**
|
||||
|
||||
@@ -724,6 +724,157 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
expect(apiKey).toBe("api-acct-solo");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["gpt-5.6-sol", "free", "plus"],
|
||||
["gpt-5.6-luna", "go", "business"],
|
||||
["gpt-5.6-sol-pro", "free", "team"],
|
||||
])("%s routes away from a less-used %s account to an eligible %s account", async (modelId, freePlan, paidPlan) => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("openai-codex", [
|
||||
{ type: "oauth", ...createCredential("acct-free", "free@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-paid", "paid@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-free",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-free",
|
||||
primary: { usedFraction: 0.01, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.01, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: freePlan, email: "free@example.com" },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-paid",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-paid",
|
||||
primary: { usedFraction: 0.8, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.8, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: paidPlan, email: "paid@example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("openai-codex", undefined, { modelId });
|
||||
expect(apiKey).toBe("api-acct-paid");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["gpt-5.6-terra", "free", "enterprise"],
|
||||
["gpt-5.6-terra-pro", "go", "pro"],
|
||||
])("%s keeps a less-used %s account in ordinary ranking ahead of %s", async (modelId, lowUsagePlan, highUsagePlan) => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("openai-codex", [
|
||||
{ type: "oauth", ...createCredential("acct-low-usage", "low-usage@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-high-usage", "high-usage@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-low-usage",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-low-usage",
|
||||
primary: { usedFraction: 0.01, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.01, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: lowUsagePlan, email: "low-usage@example.com" },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-high-usage",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-high-usage",
|
||||
primary: { usedFraction: 0.8, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.8, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: highUsagePlan, email: "high-usage@example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("openai-codex", undefined, { modelId });
|
||||
expect(apiKey).toBe("api-acct-low-usage");
|
||||
});
|
||||
|
||||
test("reranks a Terra session on a Go account when it switches to Sol", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("openai-codex", [
|
||||
{ type: "oauth", ...createCredential("acct-go", "go@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-business", "business@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-go",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-go",
|
||||
primary: { usedFraction: 0.01, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.01, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: "go", email: "go@example.com" },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-business",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-business",
|
||||
primary: { usedFraction: 0.8, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.8, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: "business", email: "business@example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
let terraSession: string | undefined;
|
||||
let terraApiKey: string | undefined;
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const sessionId = `session-terra-to-sol-${index}`;
|
||||
const apiKey = await authStorage.getApiKey("openai-codex", sessionId, {
|
||||
modelId: "gpt-5.6-terra",
|
||||
});
|
||||
if (apiKey === "api-acct-go") {
|
||||
terraSession = sessionId;
|
||||
terraApiKey = apiKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(terraApiKey).toBe("api-acct-go");
|
||||
if (!terraSession) throw new Error("expected Terra to select the lower-usage Go account");
|
||||
|
||||
const solApiKey = await authStorage.getApiKey("openai-codex", terraSession, {
|
||||
modelId: "gpt-5.6-sol",
|
||||
});
|
||||
expect(solApiKey).toBe("api-acct-business");
|
||||
});
|
||||
|
||||
test("falls back by ordinary usage ranking for Sol when no account is confirmed paid", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
await authStorage.set("openai-codex", [
|
||||
{ type: "oauth", ...createCredential("acct-free", "free@example.com") },
|
||||
{ type: "oauth", ...createCredential("acct-go", "go@example.com") },
|
||||
]);
|
||||
|
||||
usageByAccount.set(
|
||||
"acct-free",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-free",
|
||||
primary: { usedFraction: 0.8, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.8, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: "free", email: "free@example.com" },
|
||||
}),
|
||||
);
|
||||
usageByAccount.set(
|
||||
"acct-go",
|
||||
createCodexUsageReport({
|
||||
accountId: "acct-go",
|
||||
primary: { usedFraction: 0.01, resetInMs: 30 * 60 * 1000 },
|
||||
secondary: { usedFraction: 0.01, resetInMs: 6 * 24 * 60 * 60 * 1000 },
|
||||
metadata: { planType: "go", email: "go@example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("openai-codex", undefined, {
|
||||
modelId: "gpt-5.6-sol",
|
||||
});
|
||||
expect(apiKey).toBe("api-acct-go");
|
||||
});
|
||||
|
||||
test("prefers Pro accounts for codex spark models over Plus accounts", async () => {
|
||||
if (!authStorage) throw new Error("test setup failed");
|
||||
|
||||
|
||||
@@ -314,6 +314,36 @@ describe("openai-codex reasoning effort validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex reasoning effort wire mapping", () => {
|
||||
it("shifts gpt-5.6 user efforts one wire tier up via the baked effort map", async () => {
|
||||
const model = createCodexModel("gpt-5.6-sol");
|
||||
const shifted = [
|
||||
["minimal", "low"],
|
||||
["low", "medium"],
|
||||
["medium", "high"],
|
||||
["high", "xhigh"],
|
||||
["xhigh", "max"],
|
||||
] as const;
|
||||
|
||||
for (const [requested, wire] of shifted) {
|
||||
const transformed = await transformRequestBody({ model: model.id }, model, {
|
||||
reasoningEffort: requested,
|
||||
});
|
||||
expect(transformed.reasoning?.effort).toBe(wire);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps pre-5.6 efforts unshifted and passes none through unmapped", async () => {
|
||||
const gpt55 = createCodexModel("gpt-5.5");
|
||||
const unshifted = await transformRequestBody({ model: gpt55.id }, gpt55, { reasoningEffort: "xhigh" });
|
||||
expect(unshifted.reasoning?.effort).toBe("xhigh");
|
||||
|
||||
const gpt56 = createCodexModel("gpt-5.6-sol");
|
||||
const none = await transformRequestBody({ model: gpt56.id }, gpt56, { reasoningEffort: "none" });
|
||||
expect(none.reasoning?.effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex error parsing", () => {
|
||||
it("produces friendly usage-limit messages and rate limits", async () => {
|
||||
const resetAt = Math.floor(Date.now() / 1000) + 600;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
|
||||
import type { Context, FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
|
||||
const model = getBundledModel<"openai-completions">("xai", "grok-code-fast-1");
|
||||
if (!model) throw new Error("Expected bundled xAI Grok model");
|
||||
if (model.api !== "openai-completions") throw new Error(`Expected Chat Completions model, received ${model.api}`);
|
||||
const context: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }] };
|
||||
|
||||
function chatCompletionsSse(): Response {
|
||||
const chunk = (delta: unknown, finishReason: string | null) =>
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-affinity",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: model.id,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
});
|
||||
|
||||
return new Response(
|
||||
`data: ${chunk({ role: "assistant", content: "ok" }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`,
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
}
|
||||
|
||||
async function captureRequestHeaders(options: OpenAICompletionsOptions): Promise<Headers> {
|
||||
let requestHeaders: Headers | undefined;
|
||||
const fetchMock: FetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request =
|
||||
input instanceof Request
|
||||
? new Request(input, init)
|
||||
: new Request(input instanceof URL ? input.href : input, init);
|
||||
requestHeaders = request.headers;
|
||||
return chatCompletionsSse();
|
||||
};
|
||||
|
||||
await streamOpenAICompletions(model, context, {
|
||||
apiKey: "test-key",
|
||||
...options,
|
||||
fetch: fetchMock,
|
||||
}).result();
|
||||
|
||||
if (!requestHeaders) throw new Error("Expected a serialized Chat Completions request");
|
||||
return requestHeaders;
|
||||
}
|
||||
|
||||
describe("openai-completions xAI cache affinity", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
options: OpenAICompletionsOptions;
|
||||
expectedHeader: string | null;
|
||||
}> = [
|
||||
{
|
||||
name: "uses sessionId when no prompt cache key is provided",
|
||||
options: { sessionId: "session-fallback" },
|
||||
expectedHeader: "session-fallback",
|
||||
},
|
||||
{
|
||||
name: "keeps the prompt cache key stable across a distinct side-channel session",
|
||||
options: { promptCacheKey: "stable-cache-key", sessionId: "side-channel-session" },
|
||||
expectedHeader: "stable-cache-key",
|
||||
},
|
||||
{
|
||||
name: "omits automatic affinity when caching is disabled",
|
||||
options: {
|
||||
promptCacheKey: "disabled-cache-key",
|
||||
sessionId: "disabled-session",
|
||||
cacheRetention: "none",
|
||||
},
|
||||
expectedHeader: null,
|
||||
},
|
||||
{
|
||||
name: "preserves a caller-provided mixed-case affinity header",
|
||||
options: {
|
||||
promptCacheKey: "automatic-cache-key",
|
||||
sessionId: "automatic-session",
|
||||
headers: { "X-Grok-Conv-Id": "caller-affinity" },
|
||||
},
|
||||
expectedHeader: "caller-affinity",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, options, expectedHeader } of cases) {
|
||||
it(name, async () => {
|
||||
const headers = await captureRequestHeaders(options);
|
||||
|
||||
expect(headers.get("x-grok-conv-id")).toBe(expectedHeader);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -81,7 +81,6 @@ describe("provider registry auth surface", () => {
|
||||
"google-antigravity",
|
||||
"google-gemini-cli",
|
||||
"openai-codex",
|
||||
"xai-oauth",
|
||||
].sort(),
|
||||
);
|
||||
expect(PASTE_CODE_LOGIN_PROVIDERS.has("zenmux")).toBe(false);
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.15] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for Grok 4.5 model
|
||||
- Added `gpt-5.6` base models and `gpt-5.6-{luna,sol,terra}-pro` variants
|
||||
- Added `meta/muse-spark-1.1` model support
|
||||
- Added support for thinking modes on `poolside/laguna` models
|
||||
- Added generated GPT-5.6 Pro aliases (`gpt-5.6-{luna,sol,terra}-pro`) on the `openai` and `openai-codex` providers: each alias sends the base model id on the wire (`requestModelId`) with the new `reasoningMode: "pro"` marker, and re-derives from the current base rows on every catalog regeneration.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated cache read costs for Grok models
|
||||
- Reduced max token limit for Grok 4.3 model
|
||||
- Enabled prompt cache affinity for Grok models via the x-grok-conv-id header in OpenAI compatible endpoints
|
||||
- Enabled prompt cache affinity for Grok models via the x-grok-conv-id header
|
||||
- Marked direct xAI Grok Chat Completions models for `x-grok-conv-id` prompt-cache affinity.
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for GPT-5.6 (Luna, Sol, Terra) model variants
|
||||
- Enabled expanded five-tier reasoning effort scale (minimal to xhigh) for GPT-5.6 models
|
||||
- Added GPT-5.6 (Terra/Luna/Sol) support for the new `max` reasoning tier: on wire-effort APIs (OpenAI Responses, Codex, Azure, openai-compat/OpenRouter models that advertise reasoning) user efforts shift up one notch — `xhigh` sends `max`, `high` sends `xhigh` — mirroring the Claude Fable/Opus 4.7+ five-tier mapping, and the exposed ladder becomes `minimal..xhigh` with `minimal` reaching the native `low` tier. Devin's per-tier GPT-5.6 sibling rows now collapse into `gpt-5-6-{luna,sol,terra}` logical models with the same shifted routing (`xhigh` → `-max`), plus `-fast` families that keep the direct `low..xhigh` `-priority` scale since Devin serves no `-max-priority` tier.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
isKimiK27CodeModelId,
|
||||
MODELS_DEV_PROVIDER_DESCRIPTORS,
|
||||
mapModelsDevToModels,
|
||||
projectOpenAIProReasoningAliases,
|
||||
SAKANA_FUGU_STATIC_MODELS,
|
||||
stripFireworksDeepSeekThinkingToggle,
|
||||
} from "../src/provider-models/openai-compat";
|
||||
@@ -585,6 +586,10 @@ async function generateModels() {
|
||||
const name = cleanModelName(model.name);
|
||||
return name === model.name ? model : { ...model, name };
|
||||
});
|
||||
// Re-derive the first-party gpt-5.6 pro-reasoning aliases from the current
|
||||
// base rows (stale previous-snapshot aliases are dropped inside), before the
|
||||
// policy re-bake so the aliases get the same baked thinking metadata.
|
||||
allModels = projectOpenAIProReasoningAliases(allModels);
|
||||
applyGeneratedModelPolicies(allModels);
|
||||
linkOpenAIPromotionTargets(allModels);
|
||||
// Collapse effort-tier variants AFTER the policy re-bake: live-discovery
|
||||
|
||||
@@ -541,7 +541,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
|
||||
MINIMAX_PROVIDER_OR_ID_PATTERN.test(provider) || MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.id),
|
||||
emptyLengthFinishIsContextError: provider === "ollama",
|
||||
usesOpenAIToolCallIdLimit: provider === "openai",
|
||||
promptCacheSessionHeader: undefined,
|
||||
promptCacheSessionHeader: isGrok ? "x-grok-conv-id" : undefined,
|
||||
dropThinkingWhenReasoningEffort: provider === "fireworks",
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const isMimoModelIdOrName = memo((value: string): boolean => {
|
||||
return value.toLowerCase().includes("mimo");
|
||||
});
|
||||
|
||||
const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3"] as const;
|
||||
const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3", "grok-4.5"] as const;
|
||||
|
||||
/**
|
||||
* Grok SKUs that expose the wire `reasoning.effort` dial. Other Grok reasoners
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ParsedModel,
|
||||
parseAnthropicModel,
|
||||
parseKnownModel,
|
||||
parseOpenAIModel,
|
||||
semverEqual,
|
||||
semverGte,
|
||||
} from "./identity/classify";
|
||||
@@ -101,12 +102,14 @@ const MIMO_REASONING_EFFORT_MAP: Readonly<EffortMap> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Effort → wire-value map for the 5-tier adaptive scale (Opus 4.7+ and
|
||||
* Fable/Mythos 5 on the Messages API). User-facing efforts shift up one notch
|
||||
* so the top tier reaches the genuine "max" and "high" lands on Anthropic's
|
||||
* recommended "xhigh" coding/agentic default.
|
||||
* Effort → wire-value map for a shifted five-tier scale (`low..max`):
|
||||
* user-facing efforts shift up one notch so the top tier reaches the genuine
|
||||
* "max" and "high" lands on the recommended "xhigh" coding/agentic default.
|
||||
* Used by Anthropic adaptive models with a real xhigh tier (Opus 4.7+ and
|
||||
* Fable/Mythos 5 on the Messages API) and by GPT-5.6+ wire-effort models,
|
||||
* which expose the same genuine `max` tier above `xhigh`.
|
||||
*/
|
||||
export const ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER: Readonly<Partial<Record<Effort, string>>> = {
|
||||
export const SHIFTED_FIVE_TIER_EFFORT_MAP: Readonly<Partial<Record<Effort, string>>> = {
|
||||
[Effort.Minimal]: "low",
|
||||
[Effort.Low]: "medium",
|
||||
[Effort.Medium]: "high",
|
||||
@@ -295,6 +298,27 @@ function isOpenAICompatReasoningApi(api: Api): boolean {
|
||||
return api === "openai-completions" || api === "openrouter";
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5.6+ addressed through a wire `reasoning.effort`/`reasoning_effort`
|
||||
* field, where the shifted five-tier map applies. Devin (`devin-agent`)
|
||||
* selects effort by routing to per-tier sibling model ids instead and must
|
||||
* stay unmapped.
|
||||
*/
|
||||
function isGpt56PlusWireEffortModel<TApi extends Api>(spec: ModelSpec<TApi>): boolean {
|
||||
switch (spec.api) {
|
||||
case "openai-responses":
|
||||
case "openai-codex-responses":
|
||||
case "azure-openai-responses":
|
||||
case "openai-completions":
|
||||
case "openrouter":
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
const parsed = parseOpenAIModel(bareModelId(spec.id));
|
||||
return parsed !== null && semverGte(parsed.version, "5.6");
|
||||
}
|
||||
|
||||
function getModelDefinedEfforts<TApi extends Api>(
|
||||
spec: ModelSpec<TApi>,
|
||||
compat: CompatOf<TApi>,
|
||||
@@ -313,6 +337,12 @@ function getModelDefinedEfforts<TApi extends Api>(
|
||||
if (isSakanaFuguReasoningModel(spec)) {
|
||||
return FUGU_REASONING_EFFORTS;
|
||||
}
|
||||
if (isGpt56PlusWireEffortModel(spec)) {
|
||||
// Normalize stale baked/discovered `low..xhigh` surfaces to the full
|
||||
// five-tier ladder so the shifted map keeps the native `low` tier
|
||||
// reachable (user `minimal`).
|
||||
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
||||
}
|
||||
return isOpenAICompatReasoningApi(spec.api) &&
|
||||
(isMinimaxM2FamilyModelId(spec.id) ||
|
||||
isOpenAIGptOssModelId(spec.id) ||
|
||||
@@ -373,7 +403,7 @@ function inferDetectedEffortMap<TApi extends Api>(
|
||||
return MINIMAX_ANTHROPIC_ADAPTIVE_EFFORT_MAP;
|
||||
}
|
||||
return anthropicModelHasRealXHighEffort(spec, parsedModel)
|
||||
? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER
|
||||
? SHIFTED_FIVE_TIER_EFFORT_MAP
|
||||
: ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
}
|
||||
// GLM-5.2 coding SKUs accept `reasoning_effort`, but the effort dialect is
|
||||
@@ -397,6 +427,9 @@ function inferDetectedEffortMap<TApi extends Api>(
|
||||
if (isSakanaFuguReasoningModel(spec)) {
|
||||
return FUGU_REASONING_EFFORT_MAP;
|
||||
}
|
||||
if (isGpt56PlusWireEffortModel(spec)) {
|
||||
return SHIFTED_FIVE_TIER_EFFORT_MAP;
|
||||
}
|
||||
if (!isOpenAICompatReasoningApi(spec.api)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -446,7 +479,7 @@ function getOpenRouterAnthropicReasoningEffortMap(modelId: string): EffortMap |
|
||||
if (!isAnthropicAdaptiveGenAtLeast(parsed, "4.6")) return undefined;
|
||||
|
||||
const hasRealXHigh = isAnthropicAdaptiveGenAtLeast(parsed, "4.7");
|
||||
return hasRealXHigh ? ANTHROPIC_ADAPTIVE_EFFORT_MAP_5_TIER : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
return hasRealXHigh ? SHIFTED_FIVE_TIER_EFFORT_MAP : ANTHROPIC_ADAPTIVE_EFFORT_MAP_4_TIER;
|
||||
}
|
||||
|
||||
function inferSupportedEfforts<TApi extends Api>(
|
||||
@@ -474,6 +507,11 @@ function inferOpenAISupportedEfforts(model: OpenAIModel): readonly Effort[] {
|
||||
if (model.variant === "codex-mini" && semverEqual(model.version, "5.1")) {
|
||||
return GPT_5_1_CODEX_MINI_EFFORTS;
|
||||
}
|
||||
// 5.6+ exposes the full five-tier ladder: the shifted wire map spans
|
||||
// low..max, with user `minimal` reaching the native `low` tier.
|
||||
if (semverGte(model.version, "5.6")) {
|
||||
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
|
||||
}
|
||||
if (semverGte(model.version, "5.2")) {
|
||||
return GPT_5_2_PLUS_EFFORTS;
|
||||
}
|
||||
|
||||
+1100
-649
File diff suppressed because it is too large
Load Diff
@@ -821,6 +821,62 @@ export function openaiModelManagerOptions(config?: OpenAIModelManagerConfig): Mo
|
||||
};
|
||||
}
|
||||
|
||||
/** First-party gpt-5.6 SKUs that accept `reasoning: { mode: "pro" }` on the Responses APIs. */
|
||||
const OPENAI_PRO_REASONING_BASE_IDS: Record<string, true> = {
|
||||
"gpt-5.6-luna": true,
|
||||
"gpt-5.6-sol": true,
|
||||
"gpt-5.6-terra": true,
|
||||
};
|
||||
const OPENAI_PRO_REASONING_PROVIDERS: Record<string, true> = { openai: true, "openai-codex": true };
|
||||
|
||||
/**
|
||||
* A row this generator pass owns: one of the derived `gpt-5.6-*-pro` alias ids
|
||||
* on `openai`/`openai-codex` that carries the generated `reasoningMode` marker.
|
||||
* A real upstream model occupying the same id has no `reasoningMode` and is
|
||||
* never touched.
|
||||
*/
|
||||
function isGeneratedOpenAIProReasoningAlias(model: ModelSpec<Api>): boolean {
|
||||
return (
|
||||
OPENAI_PRO_REASONING_PROVIDERS[model.provider] === true &&
|
||||
model.reasoningMode !== undefined &&
|
||||
model.id.endsWith("-pro") &&
|
||||
OPENAI_PRO_REASONING_BASE_IDS[model.id.slice(0, -"-pro".length)] === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the generated pro-reasoning aliases (`gpt-5.6-*-pro`) for the
|
||||
* first-party `openai`/`openai-codex` gpt-5.6 rows. Each alias inherits the
|
||||
* base row's metadata, requests the base wire id via `requestModelId`, and
|
||||
* sets `reasoningMode: "pro"` so Responses-family request builders emit
|
||||
* `reasoning: { mode: "pro" }`. Called by the models.json generator after all
|
||||
* sources merge: stale copies of the owned aliases (previous snapshot) are
|
||||
* dropped and re-projected from the current base rows so alias metadata always
|
||||
* tracks the base, while a real upstream model that occupies an alias id wins
|
||||
* and suppresses the projection.
|
||||
*/
|
||||
export function projectOpenAIProReasoningAliases(models: readonly ModelSpec<Api>[]): ModelSpec<Api>[] {
|
||||
const kept = models.filter(model => !isGeneratedOpenAIProReasoningAlias(model));
|
||||
const ids = new Set(kept.map(model => `${model.provider}/${model.id}`));
|
||||
const out = [...kept];
|
||||
for (const model of kept) {
|
||||
if (!OPENAI_PRO_REASONING_PROVIDERS[model.provider]) continue;
|
||||
if (!OPENAI_PRO_REASONING_BASE_IDS[model.id]) continue;
|
||||
const aliasId = `${model.id}-pro`;
|
||||
const aliasKey = `${model.provider}/${aliasId}`;
|
||||
if (ids.has(aliasKey)) continue;
|
||||
ids.add(aliasKey);
|
||||
out.push({
|
||||
...model,
|
||||
id: aliasId,
|
||||
name: `${model.name} Pro`,
|
||||
requestModelId: model.id,
|
||||
reasoningMode: "pro",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Groq
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -983,6 +1039,7 @@ export const XAI_OAUTH_CURATED_MODELS: readonly XAICuratedModel[] = [
|
||||
input: ["text", "image"],
|
||||
},
|
||||
{ id: "grok-4.3", contextWindow: 1_000_000, name: "Grok 4.3", input: ["text", "image"] },
|
||||
{ id: "grok-4.5", contextWindow: 500_000, name: "Grok 4.5", input: ["text", "image"] },
|
||||
// grok-4.20-multi-agent-0309 is text-only per the bundled catalog; omit `input` for the default.
|
||||
{ id: "grok-4.20-multi-agent-0309", contextWindow: 2_000_000, name: "Grok 4.20 (Multi-Agent)" },
|
||||
{
|
||||
|
||||
@@ -691,6 +691,13 @@ export interface Model<TApi extends Api = Api> {
|
||||
* everything local (selection, caching, usage attribution) keys on `id`.
|
||||
*/
|
||||
requestModelId?: string;
|
||||
/**
|
||||
* `reasoning.mode` to send on OpenAI Responses-family requests. Set on
|
||||
* generated pro aliases (`gpt-5.6-*-pro` on `openai`/`openai-codex`) that
|
||||
* pair a base wire id (`requestModelId`) with OpenAI's pro reasoning
|
||||
* serving path. Absent everywhere else; providers omit the wire field.
|
||||
*/
|
||||
reasoningMode?: "pro";
|
||||
name: string;
|
||||
api: TApi;
|
||||
provider: Provider;
|
||||
|
||||
@@ -113,6 +113,14 @@ function thinkingPair(baseId: string, name: string): EffortVariantFamily {
|
||||
|
||||
type DevinTierRoutes = Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string>>;
|
||||
|
||||
const DEVIN_FIVE_TIER_EFFORTS: readonly Effort[] = [
|
||||
Effort.Minimal,
|
||||
Effort.Low,
|
||||
Effort.Medium,
|
||||
Effort.High,
|
||||
Effort.XHigh,
|
||||
];
|
||||
|
||||
function devinTierFamily(
|
||||
id: string,
|
||||
name: string,
|
||||
@@ -160,6 +168,44 @@ function devinTierFamily(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5.6 (Luna/Sol/Terra) adds a genuine `max` tier above `xhigh`, so the
|
||||
* standard family shifts every user effort up one notch (`minimal` → `-low`
|
||||
* … `xhigh` → `-max`), mirroring the Opus 4.7+ five-tier mapping. Devin
|
||||
* serves no `-max-priority` sibling, so the fast family keeps the direct
|
||||
* `low..xhigh` `-priority` scale.
|
||||
*/
|
||||
function devinGpt56Families(variant: "luna" | "sol" | "terra", name: string): readonly EffortVariantFamily[] {
|
||||
const base = `gpt-5-6-${variant}`;
|
||||
return [
|
||||
devinTierFamily(
|
||||
base,
|
||||
name,
|
||||
{
|
||||
off: `${base}-none`,
|
||||
minimal: `${base}-low`,
|
||||
low: `${base}-medium`,
|
||||
medium: `${base}-high`,
|
||||
high: `${base}-xhigh`,
|
||||
xhigh: `${base}-max`,
|
||||
},
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
`${base}-fast`,
|
||||
`${name} Fast`,
|
||||
{
|
||||
off: `${base}-none-priority`,
|
||||
low: `${base}-low-priority`,
|
||||
medium: `${base}-medium-priority`,
|
||||
high: `${base}-high-priority`,
|
||||
xhigh: `${base}-xhigh-priority`,
|
||||
},
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const GEMINI_3_FLASH_FAMILY_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
|
||||
const GEMINI_3_PRO_FAMILY_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
|
||||
|
||||
@@ -330,7 +376,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -353,7 +399,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -376,7 +422,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -399,7 +445,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
},
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
efforts: DEVIN_FIVE_TIER_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
},
|
||||
@@ -413,7 +459,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "MODEL_GPT_5_2_HIGH",
|
||||
xhigh: "MODEL_GPT_5_2_XHIGH",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-3-codex",
|
||||
@@ -424,7 +470,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-3-codex-high",
|
||||
xhigh: "gpt-5-3-codex-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-3-codex-fast",
|
||||
@@ -435,7 +481,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-3-codex-high-priority",
|
||||
xhigh: "gpt-5-3-codex-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4",
|
||||
@@ -447,7 +493,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-high",
|
||||
xhigh: "gpt-5-4-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4-fast",
|
||||
@@ -459,7 +505,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-high-priority",
|
||||
xhigh: "gpt-5-4-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-4-mini",
|
||||
@@ -470,7 +516,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-4-mini-high",
|
||||
xhigh: "gpt-5-4-mini-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-5",
|
||||
@@ -482,7 +528,7 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-5-high",
|
||||
xhigh: "gpt-5-5-xhigh",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
devinTierFamily(
|
||||
"gpt-5-5-fast",
|
||||
@@ -494,8 +540,11 @@ export const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
high: "gpt-5-5-high-priority",
|
||||
xhigh: "gpt-5-5-xhigh-priority",
|
||||
},
|
||||
[Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
DEVIN_FIVE_TIER_EFFORTS,
|
||||
),
|
||||
...devinGpt56Families("luna", "GPT-5.6 Luna"),
|
||||
...devinGpt56Families("sol", "GPT-5.6 Sol"),
|
||||
...devinGpt56Families("terra", "GPT-5.6 Terra"),
|
||||
devinTierFamily(
|
||||
"gemini-3-1-pro",
|
||||
"Gemini 3.1 Pro",
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("isGrokReasoningEffortCapable", () => {
|
||||
expect(isGrokReasoningEffortCapable("grok-3-mini")).toBe(true);
|
||||
expect(isGrokReasoningEffortCapable("grok-4.20-multi-agent")).toBe(true);
|
||||
expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.3")).toBe(true);
|
||||
expect(isGrokReasoningEffortCapable("xai-oauth/grok-4.5")).toBe(true);
|
||||
expect(isGrokReasoningEffortCapable("openrouter/xai/grok-3-mini")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -567,6 +567,90 @@ describe("model thinking derivation", () => {
|
||||
expect(getSupportedEfforts(model)).toEqual([]);
|
||||
expect(clampThinkingLevelForModel(model, Effort.High)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bakes the GPT-5.6 shifted five-tier effort map on wire-effort APIs", () => {
|
||||
const codex = createModel({
|
||||
id: "gpt-5.6-sol",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
|
||||
expect(codex.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortMap: {
|
||||
minimal: "low",
|
||||
low: "medium",
|
||||
medium: "high",
|
||||
high: "xhigh",
|
||||
xhigh: "max",
|
||||
},
|
||||
});
|
||||
|
||||
// Stale baked four-tier metadata (caches/discovery) normalizes back to
|
||||
// the five-tier ladder with the map attached — the wire-defaults
|
||||
// backfill path — and namespaced OpenRouter ids parse.
|
||||
const staleOpenRouter = createModel({
|
||||
id: "openai/gpt-5.6-terra",
|
||||
api: "openrouter",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
},
|
||||
});
|
||||
|
||||
expect(staleOpenRouter.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortMap: {
|
||||
minimal: "low",
|
||||
low: "medium",
|
||||
medium: "high",
|
||||
high: "xhigh",
|
||||
xhigh: "max",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pre-5.6 and Devin-routed GPT models off the shifted effort map", () => {
|
||||
const gpt55 = createModel({
|
||||
id: "gpt-5.5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
expect(gpt55.thinking).toEqual({
|
||||
mode: "effort",
|
||||
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
});
|
||||
expect(gpt55.thinking?.effortMap).toBeUndefined();
|
||||
|
||||
// Devin selects effort by routing to per-tier sibling model ids, never
|
||||
// via a wire reasoning.effort field — the shifted map must not attach.
|
||||
const devin = createModel({
|
||||
id: "gpt-5-6-sol",
|
||||
api: "devin-agent",
|
||||
provider: "devin",
|
||||
baseUrl: "https://server.codeium.com",
|
||||
thinking: {
|
||||
mode: "effort",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
|
||||
effortRouting: {
|
||||
off: "gpt-5-6-sol-none",
|
||||
minimal: "gpt-5-6-sol-low",
|
||||
low: "gpt-5-6-sol-medium",
|
||||
medium: "gpt-5-6-sol-high",
|
||||
high: "gpt-5-6-sol-xhigh",
|
||||
xhigh: "gpt-5-6-sol-max",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(devin.thinking?.effortMap).toBeUndefined();
|
||||
expect(devin.thinking?.efforts).toEqual([Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model thinking runtime helpers", () => {
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed kept-alive task subagents entering a repeated provider-call loop after an IRC wake and terminal `yield`. ([#4963](https://github.com/can1357/oh-my-pi/issues/4963))
|
||||
## [16.3.15] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Integrated testing guidance directly into the main system prompt for improved workflow cohesion
|
||||
- Moved testing guidance into the main system prompt and removed the bundled Tester subagent.
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue where unfinalized tool blocks could incorrectly pin the live-region scroll seam
|
||||
- Improved rendering of raw thinking blocks by stripping empty HTML comment noise
|
||||
- Fixed display of thinking blocks consisting entirely of hidden comment noise
|
||||
- Fixed gpt-5.6 reasoning summaries rendering literal `<!-- -->` sentinel lines in thinking blocks; empty HTML comments (and the unterminated `<!--` tail while streaming) are now dropped from the thinking display, and blocks reduced to pure comment noise are hidden entirely.
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -40,6 +40,18 @@ interface FinalizableBlock {
|
||||
* commits until the block finalizes.
|
||||
*/
|
||||
getTranscriptBlockSettledRows?(): number;
|
||||
/**
|
||||
* Whether the block is a displaceable snapshot (todo/poll card) kept
|
||||
* unfinalized only so a follow-up matching call can retract it. Paired
|
||||
* with {@link seal}: once any of its rows enters native scrollback the
|
||||
* container seals it — rows on the tape are immutable, so retraction is
|
||||
* no longer possible, and an unfinalized block would otherwise pin the
|
||||
* live-region seam open for the rest of the turn (every row committed
|
||||
* below it audit-exempt, mass-recommitted when it finally finalizes).
|
||||
*/
|
||||
isDisplaceableBlock?(): boolean;
|
||||
/** Finalize a displaceable snapshot in place (settle animation, freeze bytes). */
|
||||
seal?(): void;
|
||||
}
|
||||
|
||||
function isBlockFinalized(child: Component): boolean {
|
||||
@@ -60,6 +72,12 @@ function getBlockSettledRows(child: Component): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
}
|
||||
|
||||
/** Seal a displaceable snapshot whose rows entered native scrollback (see {@link FinalizableBlock.isDisplaceableBlock}). */
|
||||
function sealCommittedSnapshot(child: Component): void {
|
||||
const block = child as Component & FinalizableBlock;
|
||||
if (block.isDisplaceableBlock?.()) block.seal?.();
|
||||
}
|
||||
|
||||
// A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
|
||||
// separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
|
||||
// to a background-colored padding row, whose escape sequences contain `\S` and
|
||||
@@ -275,6 +293,21 @@ export class TranscriptContainer
|
||||
|
||||
const count = this.children.length;
|
||||
|
||||
// Seal displaceable snapshots whose rows are already on the tape (per the
|
||||
// previous frame's segments — the geometry the committed count was
|
||||
// computed against): immutable history can no longer be retracted, and
|
||||
// left unfinalized such a block would pin the live-region seam open below
|
||||
// it. Runs before the live-block scan so the seam unpins in this same
|
||||
// frame, and every frame so a block that BECAME displaceable after its
|
||||
// pending-preview rows committed (late result on a scrolled-off call) is
|
||||
// caught too.
|
||||
for (let i = 0; i < count && i < this.#segments.length; i++) {
|
||||
const previous = this.#segments[i]!;
|
||||
if (previous.startRow >= this.#committedRows) break;
|
||||
if (previous.rowCount === 0 || previous.component !== this.children[i]) continue;
|
||||
sealCommittedSnapshot(previous.component);
|
||||
}
|
||||
|
||||
// The commit boundary stops at the earliest still-mutating block. A
|
||||
// block that has not finalized must gate it: out-of-band inserts
|
||||
// (TTSR/todo cards) can append a finalized block *below* a tool that is
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
name: Tester
|
||||
description: Authoritative test writer. ALWAYS delegate test authoring to this agent — NEVER write tests yourself. Writes high-signal tests defending real contracts (behavior, invariants, edge cases) and refuses worthless tests that assert plumbing or restate the code.
|
||||
tools: read, grep, glob, bash, edit, write, lsp, ast_grep, ast_edit
|
||||
spawns: explore
|
||||
model: pi/task
|
||||
thinking-level: high
|
||||
---
|
||||
|
||||
<system-conventions>
|
||||
RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.
|
||||
</system-conventions>
|
||||
|
||||
You are a staff test engineer with taste. You write tests that earn their place in the suite and you delete — or refuse to write — tests that don't. You have agency: when asked for coverage that proves nothing, you write the test that would actually catch the bug instead.
|
||||
|
||||
<stakes>
|
||||
A test suite is a liability until it pays for itself. Every worthless test is negative value: it costs CI time, blocks honest refactors, and lulls the team into false confidence while the real bug ships. A test's only job is to FAIL when behavior breaks and PASS otherwise. A test that cannot fail for any real defect is noise wearing a green check. You are here because models flood codebases with exactly that noise. You write the opposite.
|
||||
</stakes>
|
||||
|
||||
<critical>
|
||||
- The litmus for every test: **name the concrete, externally observable contract it defends** — a behavior, output shape, state transition, error mapping, invariant, or a regression-prone parsing boundary. Cannot name it in one sentence? NEVER write the test.
|
||||
- Mutation test in your head: if a plausible bug — a flipped condition, an off-by-one, a wrong return value, a dropped case — would still let the test PASS, the test is worthless. Discard it.
|
||||
- You NEVER write tests that assert plumbing or restate the implementation. The forbidden classes are enumerated in `<worthless-tests>` and are hard prohibitions.
|
||||
- You MUST match the repo's existing test conventions — framework, file layout, naming, assertion style. A second convention beside an existing one is PROHIBITED.
|
||||
- NEVER test defaults (configurations, fallback values, or default environment values). If you are updating/refactoring existing tests that test defaults, you MUST delete those assertions or delete the entire default-testing tests instead.
|
||||
- You are explicitly ALLOWED to write **no tests at all** if you were spawned for a stupid reason (meaning: the change is trivial—such as docs, comments, types, exports, or simple config; the behavior is already fully covered; or any tests you would write would be worthless, restate plumbing, or test defaults). If so, state this clearly and exit.
|
||||
</critical>
|
||||
|
||||
<anti-patterns name="worthless-tests">
|
||||
NEVER write any of these. Each is a green check that survives real bugs:
|
||||
- **Config/setter echo.** Setting a value then asserting it reads back (`set(x, 30); expect(get(x)).toBe(30)`) tests the language's assignment, not your code.
|
||||
- **Source-grep.** Reading an implementation/build file and asserting on its TEXT — `expect(src).toContain("newFn()")`, `.toMatch(/import …/)`, `.not.toContain("oldName")`, "comment says X". Tests how code LOOKS, breaks on rename/reflow, passes while behavior is broken. Enforce structural facts with a type test or lint rule; enforce behavior by running the code.
|
||||
- **Tautologies.** `expect(true).toBe(true)`, `expect(x).toBe(x)`, asserting a constant equals its literal.
|
||||
- **Bare no-throw.** `expect(() => f()).not.toThrow()` with no assertion on the result. "It ran" is not a contract.
|
||||
- **Construction smoke.** "Constructs without error", "package boots", "command starts" — unless that wiring genuinely can't be exercised in-process AND a real failure mode hides there.
|
||||
- **Mock round-trips.** Asserting a mock was called with the args you just passed it. You tested the mock, not the system.
|
||||
- **Existence/shape-only.** Non-empty string, length-grew, "field is defined", "returns an object with key Y" — without asserting the VALUE that matters.
|
||||
- **Default values.** NEVER assert that default configurations, fallback properties, or default environment values match specific literals. A harmless change to a default setting must never break the tests. If you are touching or refactoring existing tests that assert defaults, **delete those assertions or the entire test instead**.
|
||||
- **Field-wiring.** Asserting an option passed in lands on a property, or that a getter returns the value the constructor stored. Test the downstream BEHAVIOR that depends on it, not the assignment.
|
||||
- **Duplicate-layer coverage.** Re-proving through mocks what an integration test already proves. Drop the narrower restatement.
|
||||
|
||||
When asked for coverage that would only produce the above, you write the test that actually exercises the behavior, and you state in your result why the requested shape was worthless.
|
||||
</anti-patterns>
|
||||
|
||||
<what-to-test>
|
||||
Aim every test at something that can actually break:
|
||||
- **Behavior & outputs** — given input, the observable result (return value, emitted event, written file, error surfaced).
|
||||
- **State transitions** — the legal and illegal moves of a stateful component; one test per invariant or transition, not one per field touched.
|
||||
- **Invariants across fields** — relationships that MUST hold (sorted output stays sorted, sum of parts equals total, encode∘decode is identity).
|
||||
- **Edge & boundary values** — zero, empty, one, max, negative, off-by-one, overflow, unicode, the value just inside and just outside a limit.
|
||||
- **Precedence & resolution** — arg beats env beats default; later override wins; first-match-wins.
|
||||
- **Error paths** — trigger the REAL failure (bad input, missing dep, denied permission) and assert the surfaced contract (error type, message mapping, exit code). NEVER instantiate the error class directly or inspect internal metadata.
|
||||
- **Regression-prone parsing boundaries** — the exact bytes where a parser/serializer historically broke; pin past regressions with a named case.
|
||||
</what-to-test>
|
||||
|
||||
<techniques>
|
||||
Reach for the right shape; do not reinvent what the repo's framework already gives you.
|
||||
- **Table-driven tests.** One body, many `{ name, input, expected }` rows covering boundaries and equivalence classes plus error cases. Name every row so a failure points at the case. The default shape for any function with a clear input→output mapping.
|
||||
- **Subtests.** Group related cases under one parent with isolated setup and independent failure reporting. Prefer over many tiny near-duplicate test functions.
|
||||
- **Property-based tests.** Assert invariants over generated inputs — round-trip identity, idempotence (`f(f(x)) == f(x)`), commutativity, monotonicity, "never panics and output stays well-formed". Catches cases you wouldn't enumerate by hand.
|
||||
- **Deterministic randomness.** Seed every generator and PRINT the seed on failure so a red run reproduces exactly. NEVER use an unseeded clock-derived source — flaky tests are worse than no tests.
|
||||
- **Fuzz tests.** For parsers, decoders, deserializers, anything eating untrusted bytes: feed mutated/random input, assert no crash and that invariants hold. Seed the corpus from known-tricky inputs and every past regression.
|
||||
- **Benchmarks.** ONLY when performance is part of the contract. Measure the operation, not setup; consume the result so it isn't optimized away; compare against a baseline or threshold. A benchmark that asserts nothing is documentation, not a test.
|
||||
- **Golden/snapshot.** Only for genuinely stable, human-reviewed output where exact bytes are the contract (codegen, serialized formats). NEVER snapshot volatile or incidental output — it becomes a rubber stamp nobody reads.
|
||||
</techniques>
|
||||
|
||||
<black-box>
|
||||
- **Test through the public API**, the way a real consumer calls it. Place tests in an EXTERNAL test package/module (separate namespace, no access to internals) so the compiler forbids reaching past the contract. This is the default and it forces you to test what callers depend on.
|
||||
- **Internal (white-box) tests only for private invariants with no observable surface** — e.g. a balancing property of an internal tree, a cache eviction order. Justify each one; if the invariant has an observable effect, test that effect from outside instead.
|
||||
- NEVER reach into private state to assert what you could observe through the public surface. Coupling tests to internals is what makes refactors painful and tempts people to delete the suite.
|
||||
</black-box>
|
||||
|
||||
<fakes>
|
||||
- **Prefer real implementations.** If the dependency is cheap and deterministic, use the real thing.
|
||||
- **Prefer hand-written fakes over mocking frameworks.** A small in-memory implementation of an interface is type-checked, readable, survives refactors, and tests behavior. Mocking frameworks pull you toward asserting call counts and argument sequences — that is plumbing, and it breaks on every harmless internal change.
|
||||
- **Mock only true external boundaries** — network, wall clock, filesystem, system randomness, third-party services — and even there a fake beats a mock. Inject the boundary; never patch globals.
|
||||
- NEVER use module-registry mocking that leaks across test files. Spy on the imported object and restore in teardown.
|
||||
</fakes>
|
||||
|
||||
<isolation>
|
||||
Tests MUST be full-suite safe and order-independent, not merely file-local safe.
|
||||
- **No timing dependence.** NEVER `sleep`/`setTimeout`-race to "let it settle". Inject a controllable clock and advance it; wait on a condition, signal, or promise, never a wall-clock duration. Real-time waits are the #1 source of flake.
|
||||
- **No environment pollution.** NEVER leak env vars, temp files, global singletons, `process.env`/`process.platform`/`Bun.*` mutations, or monkeypatches past the test. Use per-test setup with restore in teardown. A test that passes alone but poisons a later file is broken.
|
||||
- **Deterministic.** No dependence on map/iteration order, filesystem ordering, locale, timezone, or concurrency interleaving unless that ordering IS the contract under test.
|
||||
- **Hermetic.** No real network or real time. Each test creates and tears down its own fixtures.
|
||||
</isolation>
|
||||
|
||||
<workflow>
|
||||
1. **Study the code under test.** Read exact signatures, return types, and error paths with `lsp`/`read` — NEVER guess an API. Spawn `explore` for unfamiliar areas.
|
||||
2. **Study existing tests.** Find the framework, file layout, naming, fake/fixture helpers, and assertion style. You MUST reuse them. `grep`/`glob` for sibling test files.
|
||||
3. **Enumerate contracts.** List the observable behaviors, invariants, edge cases, and error mappings worth defending. Drop anything that fails the `<critical>` litmus.
|
||||
4. **Pick the shape** per `<techniques>` — table, property, fuzz, benchmark, or a focused unit/integration test.
|
||||
5. **Write the tests**, matching repo conventions exactly. Assert semantic content; assert exact bytes ONLY where downstream parses them.
|
||||
6. **Run them and verify they have teeth.** Execute the suite with the repo's runner; confirm green. Then confirm each test can FAIL: mentally (or by a throwaway mutation) check that a real defect reddens it. A test you never saw fail is unproven.
|
||||
</workflow>
|
||||
|
||||
<verify>
|
||||
- You MUST run the tests you wrote with the project's test command and confirm they pass.
|
||||
- You MUST confirm they are not vacuous: a test that passes against broken code is a defect you authored. When cheap, perturb the implementation to watch the test fail, then revert.
|
||||
- Run ONLY the tests you added or touched unless asked for the full suite.
|
||||
- Report each test by the contract it defends — not "added N tests", but "covers <behavior/invariant/edge>".
|
||||
</verify>
|
||||
|
||||
<critical>
|
||||
- A test exists to FAIL on a real bug. No nameable contract, or no plausible bug would redden it → NEVER write it.
|
||||
- NEVER assert plumbing, restate the implementation, or grep the source. Test observable behavior through the public surface.
|
||||
- No timing races, no environment pollution, deterministic and order-independent — full-suite safe.
|
||||
- NEVER test defaults. If updating tests that do, delete them instead.
|
||||
- You are explicitly ALLOWED to write **no tests at all** if you were spawned for a stupid reason (trivial changes, already covered, or if any possible test would be worthless/test defaults).
|
||||
- You MUST keep going until the tests are written, passing, and proven to have teeth (unless skipped per above).
|
||||
</critical>
|
||||
@@ -182,9 +182,11 @@ EXECUTION WORKFLOW
|
||||
{{#has tools "ask"}}- Ask before destructive commands or deleting code you didn't write.{{else}}- Don't run destructive git commands or delete code you didn't write.{{/has}}
|
||||
|
||||
# 5. Verify
|
||||
- NEVER yield non-trivial work without proof: tests, E2E, browsing, or QA. Run only tests you added or modified unless asked otherwise.
|
||||
- Test behavior, using tester agent where available. Assert logical behavior, not current state.
|
||||
- Aim at conditional branches, edge values, invariants across fields, and error handling versus silent broken results.
|
||||
- NEVER yield non-trivial work without proof: tests, E2E, browsing, or QA.
|
||||
- Every test MUST defend an observable contract and fail on a plausible bug.
|
||||
- Test behavior, boundaries, invariants, transitions, precedence, and real errors—not plumbing, source text, or incidental defaults.
|
||||
- Match existing conventions; keep tests deterministic, isolated, and full-suite safe.
|
||||
- Run only touched tests; small/no-test changes still REQUIRE a focused behavioral smoke test.
|
||||
|
||||
# 6. Cleanup
|
||||
Changelog, tests, docs, and removing scaffolding are the LAST phase—NEVER skipped, but gated on the request demonstrably working.
|
||||
|
||||
@@ -15,7 +15,6 @@ import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
|
||||
import planMd from "../prompts/agents/plan.md" with { type: "text" };
|
||||
import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
|
||||
import taskMd from "../prompts/agents/task.md" with { type: "text" };
|
||||
import testerMd from "../prompts/agents/tester.md" with { type: "text" };
|
||||
|
||||
import type { AgentDefinition, AgentSource } from "./types";
|
||||
|
||||
@@ -47,7 +46,6 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
||||
{ fileName: "designer.md", template: designerMd },
|
||||
{ fileName: "reviewer.md", template: reviewerMd },
|
||||
{ fileName: "librarian.md", template: librarianMd },
|
||||
{ fileName: "tester.md", template: testerMd },
|
||||
{
|
||||
fileName: "task.md",
|
||||
frontmatter: {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
|
||||
// Single-entry memo for the proseOnly formatting path. During a streaming tick
|
||||
// the same growing thinking text is formatted up to three times (reveal count,
|
||||
// reveal slice, component render); this collapses them to one computation. The
|
||||
// `proseOnly === false` branch is a passthrough and never consults the cache, so
|
||||
// the key can be the text alone. A single entry is enough for the common case of
|
||||
// one active thinking block and never regresses (a miss recomputes exactly as
|
||||
// before).
|
||||
let formatCacheKey = "";
|
||||
let formatCacheValue = "";
|
||||
// Single-slot-per-mode memo for formatThinkingForDisplay. During a streaming
|
||||
// tick the same growing thinking text is formatted up to three times (reveal
|
||||
// count, reveal slice, component render); this collapses them to one
|
||||
// computation. Prose and raw modes produce different output for the same text,
|
||||
// so each mode keeps its own slot. One entry per mode is enough for the common
|
||||
// case of one active thinking block and never regresses (a miss recomputes
|
||||
// exactly as before).
|
||||
let proseCacheKey = "";
|
||||
let proseCacheValue = "";
|
||||
let rawCacheKey = "";
|
||||
let rawCacheValue = "";
|
||||
|
||||
export function canonicalizeMessage(text: string | null | undefined): string {
|
||||
if (!text) return "";
|
||||
@@ -22,9 +24,35 @@ export function canonicalizeMessage(text: string | null | undefined): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// gpt-5.x reasoning summaries pad every summary part with an empty HTML
|
||||
// comment (`**Headline**\n\n<!-- -->`), streamed as a `<!--` delta followed by
|
||||
// ` -->`. Comments with actual content are left untouched.
|
||||
const EMPTY_COMMENT_RE = /^<!--\s*-->$/;
|
||||
const OPEN_COMMENT_RE = /^<!--\s*$/;
|
||||
|
||||
/**
|
||||
* Whether `line` is reasoning-summary comment noise: an empty HTML comment,
|
||||
* or its still-unterminated `<!--` prefix on the last line while streaming.
|
||||
*/
|
||||
function isCommentNoise(line: string, isLastLine: boolean): boolean {
|
||||
const trimmed = line.trim();
|
||||
return EMPTY_COMMENT_RE.test(trimmed) || (isLastLine && OPEN_COMMENT_RE.test(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking text prepared for display. Both modes drop empty `<!-- -->`
|
||||
* sentinel lines outside code fences (see {@link isCommentNoise}); prose-only
|
||||
* mode additionally elides fenced code down to a trailing ellipsis.
|
||||
*/
|
||||
export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
|
||||
if (!proseOnly || !text) return text;
|
||||
if (text === formatCacheKey) return formatCacheValue;
|
||||
if (!text) return text;
|
||||
const hasComment = text.includes("<!--");
|
||||
if (proseOnly) {
|
||||
if (text === proseCacheKey) return proseCacheValue;
|
||||
} else {
|
||||
if (!hasComment) return text;
|
||||
if (text === rawCacheKey) return rawCacheValue;
|
||||
}
|
||||
|
||||
const lines = text.split("\n");
|
||||
const resultLines: string[] = [];
|
||||
@@ -56,22 +84,31 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
const open = FENCE.exec(line);
|
||||
|
||||
if (inFence) {
|
||||
const close = FENCE.exec(line);
|
||||
// A closing fence is the same char, at least as long, with nothing else on the line.
|
||||
if (
|
||||
open &&
|
||||
open[2]![0] === fenceChar &&
|
||||
open[2]!.length >= fenceLen &&
|
||||
line.slice(open[1]!.length + open[2]!.length).trim() === ""
|
||||
close &&
|
||||
close[2]![0] === fenceChar &&
|
||||
close[2]!.length >= fenceLen &&
|
||||
line.slice(close[1]!.length + close[2]!.length).trim() === ""
|
||||
) {
|
||||
inFence = false;
|
||||
fenceChar = "";
|
||||
fenceLen = 0;
|
||||
}
|
||||
// We skip all internal lines of a code fence.
|
||||
} else if (open) {
|
||||
// Prose mode skips all fence lines; raw mode keeps them verbatim
|
||||
// (comment markers inside fences are code, not noise).
|
||||
if (!proseOnly) resultLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop the whole line so `**Headline**\n\n<!-- -->` leaves no blank tail.
|
||||
if (hasComment && isCommentNoise(line, i === lines.length - 1)) continue;
|
||||
|
||||
const open = FENCE.exec(line);
|
||||
if (open) {
|
||||
const marker = open[2]!;
|
||||
const ch = marker[0]!;
|
||||
// A backtick fence's info string may not contain a backtick.
|
||||
@@ -79,18 +116,25 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri
|
||||
inFence = true;
|
||||
fenceChar = ch;
|
||||
fenceLen = marker.length;
|
||||
appendEllipsis();
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
if (proseOnly) {
|
||||
appendEllipsis();
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
resultLines.push(line);
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
const formatted = resultLines.join("\n");
|
||||
formatCacheKey = text;
|
||||
formatCacheValue = formatted;
|
||||
if (proseOnly) {
|
||||
proseCacheKey = text;
|
||||
proseCacheValue = formatted;
|
||||
} else {
|
||||
rawCacheKey = text;
|
||||
rawCacheValue = formatted;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
@@ -99,9 +143,11 @@ export function hasDisplayableThinking(
|
||||
text: string | null | undefined,
|
||||
formattedText: string | null | undefined,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (!formattedText) return false;
|
||||
return formattedText.length > 0 && canonicalizeMessage(text).length > 0;
|
||||
if (!text || !formattedText) return false;
|
||||
// Visibility keys off the formatted text: a block whose raw text is only
|
||||
// comment noise (`<!-- -->\n`) formats to whitespace and stays hidden. The
|
||||
// raw canonicalize check still hides dot/ellipsis-only placeholder blocks.
|
||||
return formattedText.trim().length > 0 && canonicalizeMessage(text).length > 0;
|
||||
}
|
||||
|
||||
/** Whether an assistant message contains thinking content the TUI can reveal. */
|
||||
|
||||
@@ -727,3 +727,163 @@ describe("TranscriptContainer renderViewportTail", () => {
|
||||
expect([...container.renderViewportTail(W, 0)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A displaceable snapshot (todo/poll card): kept unfinalized only so a matching
|
||||
// follow-up call can retract it. Mirrors ToolExecutionComponent.seal — sealing
|
||||
// finalizes the block in place and it stops reporting displaceable. A pending
|
||||
// tool starts non-displaceable and becomes a displaceable snapshot only when
|
||||
// its successful result arrives (`makeDisplaceable`).
|
||||
class DisplaceableBlock implements Component {
|
||||
sealCount = 0;
|
||||
#sealed = false;
|
||||
#displaceable: boolean;
|
||||
#lines: string[];
|
||||
constructor(lines: string[], displaceable = true) {
|
||||
this.#lines = lines;
|
||||
this.#displaceable = displaceable;
|
||||
}
|
||||
makeDisplaceable(): void {
|
||||
this.#displaceable = true;
|
||||
}
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
return this.#sealed;
|
||||
}
|
||||
isDisplaceableBlock(): boolean {
|
||||
return this.#displaceable && !this.#sealed;
|
||||
}
|
||||
seal(): void {
|
||||
this.sealCount++;
|
||||
this.#sealed = true;
|
||||
}
|
||||
invalidate(): void {}
|
||||
render(_width: number): string[] {
|
||||
return [...this.#lines];
|
||||
}
|
||||
}
|
||||
|
||||
// Seal-on-commit: rows on the native-scrollback tape are immutable, so once the
|
||||
// commit boundary covers any of a displaceable snapshot's rows the container
|
||||
// must seal it in place — retracting it would strand an orphaned copy in
|
||||
// terminal history, and left unfinalized it would pin the live-region seam
|
||||
// open. setNativeScrollbackCommittedRows is a pure store; the seal walk runs at
|
||||
// the start of the NEXT render, over the previous frame's segments (the
|
||||
// geometry the committed count was computed against), before the seam scan so
|
||||
// the seam unpins in that same frame.
|
||||
describe("TranscriptContainer seal-on-commit", () => {
|
||||
const W = 40;
|
||||
|
||||
// history(0) | sep(1) | todo-header(2) | todo-body(3); the leading
|
||||
// separator row belongs to the card's segment (segment.startRow = 1).
|
||||
function cardAfterHistory(displaceable = true): { container: TranscriptContainer; card: DisplaceableBlock } {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const card = new DisplaceableBlock(["todo-header", "todo-body"], displaceable);
|
||||
container.addChild(card);
|
||||
expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body"]);
|
||||
return { container, card };
|
||||
}
|
||||
|
||||
it("seals on the next render once the boundary covers the block's rows", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// The unsealed card pins the live-region seam at its own rows.
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
// Rows 0..2 (through the card's header) are immutable history now.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
// The setter is a pure store: sealing waits for the next compose.
|
||||
expect(card.sealCount).toBe(0);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
expect(container.isBlockUncommitted(card)).toBe(false);
|
||||
// The seal pre-pass ran before the seam scan: the SAME render already
|
||||
// reports the seam unpinned (no still-mutating block left).
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not seal while the boundary stays above the block", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// Only "history" committed; the card's rows are all still retractable.
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(card)).toBe(true);
|
||||
});
|
||||
|
||||
it("never seals across same-value or decreasing republishes above the block", () => {
|
||||
const { container, card } = cardAfterHistory();
|
||||
// The engine republishes the committed count every frame (compose and
|
||||
// post-emit): repeated same-value and decreasing stores above the
|
||||
// card's rows never accumulate into a seal.
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
container.setNativeScrollbackCommittedRows(1);
|
||||
container.render(W);
|
||||
container.setNativeScrollbackCommittedRows(0);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(card)).toBe(true);
|
||||
});
|
||||
|
||||
it("seals exactly once as the boundary sweeps past the block in stages", () => {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const card = new DisplaceableBlock(["todo-header", "todo-body"]);
|
||||
container.addChild(card);
|
||||
container.addChild(new MutableBlock(["tail"]));
|
||||
expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body", "", "tail"]);
|
||||
// First crossing (through the header) seals; the sealed block stops
|
||||
// reporting displaceable.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
// A later sweep past the whole block, and every subsequent render at
|
||||
// that boundary, must not seal again.
|
||||
container.setNativeScrollbackCommittedRows(6);
|
||||
container.render(W);
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
});
|
||||
|
||||
it("never seals a displaceable block with an empty contribution", () => {
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["history"]));
|
||||
const empty = new DisplaceableBlock([]);
|
||||
container.addChild(empty);
|
||||
container.addChild(new MutableBlock(["tail"]));
|
||||
expect(container.render(W)).toEqual(["history", "", "tail"]);
|
||||
// None of the block's rows are on the tape: nothing to seal, ever.
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
expect(empty.sealCount).toBe(0);
|
||||
expect(container.isBlockUncommitted(empty)).toBe(true);
|
||||
});
|
||||
|
||||
it("walks past blocks without the displaceable protocol", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const plain = new MutableBlock(["plain-block"]);
|
||||
container.addChild(plain);
|
||||
const card = new DisplaceableBlock(["todo-header"]);
|
||||
container.addChild(card);
|
||||
expect(container.render(W)).toEqual(["plain-block", "", "todo-header"]);
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
// The pre-pass visits the plain block first (its rows also committed);
|
||||
// absent duck-typed methods are a no-op and the card below still seals.
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
expect(container.isBlockUncommitted(plain)).toBe(false);
|
||||
});
|
||||
|
||||
it("seals a block that became displaceable after its rows committed", () => {
|
||||
// A pending tool's preview rows scroll into native scrollback before
|
||||
// its successful result arrives; only then does the block become a
|
||||
// displaceable snapshot. The walk runs every render, so the flip is
|
||||
// caught on the next compose — not only when the boundary moves.
|
||||
const { container, card } = cardAfterHistory(false);
|
||||
container.setNativeScrollbackCommittedRows(3);
|
||||
container.render(W);
|
||||
// Rows committed while not displaceable: nothing to seal yet.
|
||||
expect(card.sealCount).toBe(0);
|
||||
card.makeDisplaceable();
|
||||
container.render(W);
|
||||
expect(card.sealCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-2
@@ -40,6 +40,10 @@ function makeStreamingMessage(content: AssistantMessage["content"]): AssistantMe
|
||||
};
|
||||
}
|
||||
|
||||
// Components the controller mounts during a dispatch (pending tool previews).
|
||||
// Sealed in afterEach so their spinner intervals never outlive the test file.
|
||||
const mountedComponents: { seal?(): void }[] = [];
|
||||
|
||||
function createFixture(streamingMessage: AssistantMessage) {
|
||||
const markTranscriptBlockFinalized = vi.fn();
|
||||
const streamingComponent = {
|
||||
@@ -49,14 +53,14 @@ function createFixture(streamingMessage: AssistantMessage) {
|
||||
const ctx = {
|
||||
isInitialized: true,
|
||||
init: vi.fn(async () => {}),
|
||||
ui: { requestRender: vi.fn() },
|
||||
ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() },
|
||||
statusLine: { invalidate: vi.fn() },
|
||||
updateEditorTopBorder: vi.fn(),
|
||||
streamingComponent,
|
||||
streamingMessage,
|
||||
pendingTools: new Map(),
|
||||
noteDisplayableThinkingContent: vi.fn(() => false),
|
||||
chatContainer: { addChild: vi.fn() },
|
||||
chatContainer: { addChild: vi.fn((child: { seal?(): void }) => mountedComponents.push(child)) },
|
||||
toolOutputExpanded: false,
|
||||
settings,
|
||||
session: { getToolByName: () => undefined },
|
||||
@@ -84,6 +88,7 @@ async function dispatchUpdate(message: AssistantMessage) {
|
||||
|
||||
describe("EventController finalizes assistant block when tool-call args stream", () => {
|
||||
afterEach(() => {
|
||||
for (const component of mountedComponents.splice(0)) component.seal?.();
|
||||
resetSettingsForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Local SQLite memory engine for Oh My Pi agents",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
Vendored
+1
-1
@@ -170,7 +170,7 @@ export declare function __ompInstallTokioRuntime(): void
|
||||
* `packages/natives/native/index.js` (which derives the name from
|
||||
* `package.json#version`).
|
||||
*/
|
||||
export declare function __piNativesV16_3_13(): void
|
||||
export declare function __piNativesV16_3_15(): void
|
||||
|
||||
/**
|
||||
* Apply conservative pre-execution rewrites to a bash command.
|
||||
|
||||
@@ -24,7 +24,7 @@ export const Shell = nativeBindings.Shell;
|
||||
|
||||
// functions
|
||||
export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime;
|
||||
export const __piNativesV16_3_13 = nativeBindings.__piNativesV16_3_13;
|
||||
export const __piNativesV16_3_15 = nativeBindings.__piNativesV16_3_15;
|
||||
export const applyBashFixups = nativeBindings.applyBashFixups;
|
||||
export const astEdit = nativeBindings.astEdit;
|
||||
export const astGrep = nativeBindings.astGrep;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
|
||||
"type": "module",
|
||||
"homepage": "https://omp.sh",
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Regression for https://github.com/can1357/oh-my-pi/issues/4866.
|
||||
*
|
||||
* "When bash command times out, it exits/crashes OMP as a whole" (WSL).
|
||||
*
|
||||
* Root cause: the native shell output bridge (`bridge_chunks` in
|
||||
* `crates/pi-natives/src/shell.rs` + `emit_chunk` in
|
||||
* `crates/pi-shell/src/shell.rs`) queued decoded output chunks into an
|
||||
* unbounded cross-thread channel and fired the JS threadsafe function
|
||||
* non-blocking, with no backpressure. A producer outrunning the JS consumer
|
||||
* (`yes | cat` runs as in-process uutils builtins at memory speed; any
|
||||
* output-heavy long task qualifies) ballooned the native queue by gigabytes
|
||||
* before the timeout fired, and the callback flood then kept the JS event
|
||||
* loop saturated so the deadline machinery ran tens of seconds late.
|
||||
* Measured on the pre-fix baseline (macOS arm64): a `timeoutMs: 1500` run
|
||||
* through the bash executor resolved after ~30-36 s having forwarded ~6.9 GB,
|
||||
* with process RSS pinned at ~7 GB. On WSL's memory-capped VM that backlog
|
||||
* trips the Linux OOM killer, which SIGKILLs the whole OMP process — the
|
||||
* reported "crashes OMP as a whole".
|
||||
*
|
||||
* This test models the real consumer (OutputSink sanitize/tail/render work)
|
||||
* with a deliberately slow `onChunk` (~1 ms per callback) and pins the fixed
|
||||
* contract for both the one-shot (`executeShell`) and persistent-session
|
||||
* (`Shell.run`) paths:
|
||||
* 1. The run resolves near its deadline (raced against a generous window)
|
||||
* instead of being dragged out by an unbounded backlog drain. On the
|
||||
* pre-fix bridge the drain alone needs minutes (tens of thousands of
|
||||
* queued 64 KiB batches through a ~1 ms consumer).
|
||||
* 2. Native memory stays bounded: RSS growth over the run stays far under
|
||||
* the gigabytes the unbounded queue accumulated (bounded(64) queue ×
|
||||
* 64 KiB batches plus JS churn).
|
||||
* 3. The run still reports `timedOut`, so timeout annotation and session
|
||||
* quarantine behave as before.
|
||||
*
|
||||
* Bounds carry >4x headroom on both sides of every threshold (fixed path
|
||||
* measured: resolve ≈1 s, RSS delta ≈60 MiB; baseline: unresolved at 6 s,
|
||||
* RSS delta ≥2 GiB), so the test stays robust on slow CI hosts while the
|
||||
* failure mode overshoots by orders of magnitude.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executeShell, Shell, type ShellRunResult } from "../native/index.js";
|
||||
|
||||
/** `yes` and `cat` are in-process uutils builtins: output is produced at
|
||||
* memory speed, which is what made the unbounded bridge lethal. */
|
||||
const FAST_PRODUCER = "yes issue-4866-crash-line | cat";
|
||||
const TIMEOUT_MS = 800;
|
||||
/** Window the timed-out run must resolve within (fixed path: ~1 s; pre-fix
|
||||
* baseline is still draining its multi-GB backlog minutes later). */
|
||||
const RESOLVE_WINDOW_MS = 8_000;
|
||||
/** RSS growth budget. Fixed path: tens of MiB. Pre-fix: multiple GiB. */
|
||||
const MAX_RSS_DELTA_BYTES = 512 * 1024 * 1024;
|
||||
/** Per-callback consumer cost emulating OutputSink/TUI work. */
|
||||
const CONSUMER_STALL_MS = 1;
|
||||
const TEST_BUDGET_MS = 60_000;
|
||||
|
||||
const posixIt = process.platform === "win32" ? it.skip : it;
|
||||
|
||||
// Real-clock integration test (ts-no-test-timers exception): the run under
|
||||
// test is a native tokio shell execution behind the N-API boundary — fake JS
|
||||
// timers cannot advance the native runtime's clock, and the defect being
|
||||
// pinned is precisely a real-time liveness failure (the JS event loop and
|
||||
// deadline machinery starved by the callback flood). The stall emulates
|
||||
// synchronous per-callback consumer cost (CPU work, not scheduling), and the
|
||||
// resolve window is a liveness bound, not a synchronization guess.
|
||||
async function runTimedOutFastProducer(
|
||||
run: (onChunk: (err: Error | null, chunk: string) => void) => Promise<ShellRunResult>,
|
||||
): Promise<void> {
|
||||
const rssBefore = process.memoryUsage.rss();
|
||||
const slowConsumer = (_err: Error | null, chunk: string) => {
|
||||
if (chunk) Bun.sleepSync(CONSUMER_STALL_MS);
|
||||
};
|
||||
|
||||
const settled = run(slowConsumer).then(result => ({ done: true as const, result }));
|
||||
const raced = await Promise.race([settled, Bun.sleep(RESOLVE_WINDOW_MS).then(() => ({ done: false as const }))]);
|
||||
const rssDelta = process.memoryUsage.rss() - rssBefore;
|
||||
|
||||
// (2) Bounded native memory — the unbounded bridge queued gigabytes here.
|
||||
expect(rssDelta).toBeLessThan(MAX_RSS_DELTA_BYTES);
|
||||
// (1) Timely resolution — the unbounded bridge dragged the run out for
|
||||
// minutes past its deadline.
|
||||
expect(raced.done).toBe(true);
|
||||
if (raced.done) {
|
||||
// (3) Timeout is still reported as such.
|
||||
expect(raced.result.timedOut).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe("issue 4866: bash timeout must not flood the output bridge", () => {
|
||||
posixIt(
|
||||
"one-shot executeShell: fast producer with slow consumer times out near its deadline with bounded memory",
|
||||
async () => {
|
||||
await runTimedOutFastProducer(onChunk =>
|
||||
executeShell({ command: FAST_PRODUCER, timeoutMs: TIMEOUT_MS }, onChunk),
|
||||
);
|
||||
},
|
||||
TEST_BUDGET_MS,
|
||||
);
|
||||
|
||||
posixIt(
|
||||
"persistent Shell.run: fast producer with slow consumer times out near its deadline with bounded memory",
|
||||
async () => {
|
||||
const shell = new Shell();
|
||||
await runTimedOutFastProducer(onChunk =>
|
||||
shell.run({ command: FAST_PRODUCER, timeoutMs: TIMEOUT_MS }, onChunk),
|
||||
);
|
||||
},
|
||||
TEST_BUDGET_MS,
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Bitmap-frame context compression for vision-capable LLMs",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Local observability dashboard for pi AI usage statistics",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Swarm orchestration extension for omp",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Derek Rynd",
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.3.14] - 2026-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed race condition where scrollback rows could be incorrectly retracted between render frames
|
||||
|
||||
## [16.3.13] - 2026-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
+29
-2
@@ -1137,8 +1137,14 @@ export class TUI extends Container {
|
||||
// Feed the engine's committed-row claim (from the previous frame's
|
||||
// emit) before rendering so the child can skip re-deriving blocks
|
||||
// that already live in immutable native scrollback. Reused segments
|
||||
// skip this: they never call render(), so the signal is moot.
|
||||
setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset));
|
||||
// skip this: they never call render(), so the signal is moot. The
|
||||
// claim is in the previous frame's coordinates and never exceeds
|
||||
// the rows the child actually contributed there — history that
|
||||
// advanced into LATER root children must not read as this child's
|
||||
// own future rows being pre-committed.
|
||||
const prevRows = previous !== undefined && previous.component === child ? previous.rowCount : 0;
|
||||
const prevStart = previous !== undefined && previous.component === child ? previous.start : offset;
|
||||
setNativeScrollbackCommittedRows(child, Math.min(prevRows, Math.max(0, this.#committedRows - prevStart)));
|
||||
childLines = child.render(width);
|
||||
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
||||
if (liveRegionStart !== undefined) {
|
||||
@@ -2802,6 +2808,7 @@ export class TUI extends Container {
|
||||
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
||||
this.#clearScrollbackOnNextRender = false;
|
||||
this.#hasEverRendered = true;
|
||||
this.#publishCommittedRows();
|
||||
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
||||
return;
|
||||
}
|
||||
@@ -2829,6 +2836,7 @@ export class TUI extends Container {
|
||||
} else {
|
||||
this.#committedPrefixAuditRows = Math.min(preAuditRows, this.#committedRows);
|
||||
}
|
||||
this.#publishCommittedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2853,6 +2861,25 @@ export class TUI extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the post-emit committed-row count to root children that implement
|
||||
* {@link NativeScrollbackCommittedRows}. Compose feeds the same signal
|
||||
* before each child render (see {@link render}), but guards that run
|
||||
* BETWEEN frames — e.g. a controller consulting the transcript's
|
||||
* committed boundary to decide whether a displaceable block may still be
|
||||
* retracted — would otherwise observe a count one frame stale and retract
|
||||
* rows that just entered immutable native scrollback, stranding an
|
||||
* orphaned copy above the repainted block.
|
||||
*/
|
||||
#publishCommittedRows(): void {
|
||||
for (const segment of this.#frameSegments) {
|
||||
setNativeScrollbackCommittedRows(
|
||||
segment.component,
|
||||
Math.min(segment.rowCount, Math.max(0, this.#committedRows - segment.start)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the composed frame for emission, in place. Rows below
|
||||
* `#preparedValidRows` are already prepared against the current frame (the
|
||||
|
||||
@@ -80,6 +80,23 @@ class CommittedRowsProbe extends SeamLineList implements NativeScrollbackCommitt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the compose-time probe with the raw wire: every value the engine
|
||||
* pushes through `setNativeScrollbackCommittedRows`, in arrival order —
|
||||
* including the post-emit publish that lands *between* frames. Guards that
|
||||
* run between frames (a controller deciding whether a displaceable block may
|
||||
* still be retracted) read exactly this last value; if it lags the emit by
|
||||
* one frame they retract rows that already entered immutable history.
|
||||
*/
|
||||
class CommittedRowsWireProbe extends CommittedRowsProbe {
|
||||
received: number[] = [];
|
||||
|
||||
override setNativeScrollbackCommittedRows(rows: number): void {
|
||||
this.received.push(rows);
|
||||
super.setNativeScrollbackCommittedRows(rows);
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(term: VirtualTerminal): Promise<void> {
|
||||
const nextTick = Promise.withResolvers<void>();
|
||||
process.nextTick(nextTick.resolve);
|
||||
@@ -604,6 +621,130 @@ describe("streaming scrollback — visual record", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes the post-emit committed count between frames — never one frame stale", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
overrideProbe(term, undefined);
|
||||
const tui = new TUI(term);
|
||||
const probe = new CommittedRowsWireProbe([]);
|
||||
|
||||
try {
|
||||
tui.addChild(probe);
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Nothing has scrolled: the between-frames claim is 0 — no phantom rows.
|
||||
expect(probe.received.at(-1)).toBe(0);
|
||||
|
||||
// One frame grows past the viewport; its emit scrolls rows into
|
||||
// native scrollback. No further render is requested — whatever the
|
||||
// probe last received IS the claim a between-frames guard consults.
|
||||
probe.setLines(rows("hist-", 20));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
// Compose ran before the emit advanced the boundary, so this frame's
|
||||
// render() saw the pre-emit count. The emit must then push the fresh
|
||||
// count: with compose-only propagation the last received value would
|
||||
// still equal the stale compose view, and a guard would retract rows
|
||||
// that just became immutable — stranding an orphaned copy in history.
|
||||
const composeView = probe.committedRowsAtRender.at(-1)!;
|
||||
const betweenFrames = probe.received.at(-1)!;
|
||||
expect(betweenFrames).toBeGreaterThan(composeView);
|
||||
// The fresh claim is the truth: exactly the rows above the window
|
||||
// (tape = committed history rows + the 8-row grid).
|
||||
expect(betweenFrames).toBe(tape(term).length - 8);
|
||||
|
||||
// A frame that commits nothing must restate the boundary verbatim on
|
||||
// every push — compose feed and post-emit publish alike. No regress,
|
||||
// no phantom advance.
|
||||
const wireLength = probe.received.length;
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
expect(probe.received.length).toBeGreaterThan(wireLength);
|
||||
for (const value of probe.received.slice(wireLength)) {
|
||||
expect(value).toBe(betweenFrames);
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes the post-emit committed count on the full-paint replay path too", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
overrideProbe(term, undefined);
|
||||
const tui = new TUI(term);
|
||||
// Content taller than the viewport before the first paint: the initial
|
||||
// frame takes the full-paint path, whose replay commits (frame - height)
|
||||
// rows in one shot on a separate exit from the ordinary update emit.
|
||||
const probe = new CommittedRowsWireProbe(rows("hist-", 20));
|
||||
|
||||
try {
|
||||
tui.addChild(probe);
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Compose fed the pre-emit count (0); the replay committed 12 rows.
|
||||
// The full-paint return must publish the fresh count too — leaving
|
||||
// it stale until the next compose is the same one-frame lag.
|
||||
const composeView = probe.committedRowsAtRender.at(-1)!;
|
||||
const betweenFrames = probe.received.at(-1)!;
|
||||
expect(betweenFrames).toBeGreaterThan(composeView);
|
||||
expect(betweenFrames).toBe(tape(term).length - 8);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps each child's committed-count feed to its own extent", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
overrideProbe(term, undefined);
|
||||
const tui = new TUI(term);
|
||||
// A short header above a tall overflowing body: the engine's committed
|
||||
// boundary sails past the header's 2-row extent. Both feeds are in the
|
||||
// child's own coordinates and must saturate at what the child actually
|
||||
// contributed — an unclamped count would make rows the header appends
|
||||
// LATER read as already-committed, exempting them from ever painting.
|
||||
const header = new CommittedRowsWireProbe(rows("hdr-", 2));
|
||||
const body = new CommittedRowsWireProbe([]);
|
||||
|
||||
try {
|
||||
tui.addChild(header);
|
||||
tui.addChild(body);
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
body.setLines(rows("body-", 20));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
// 22-row frame in an 8-row window: 14 rows committed, the boundary
|
||||
// 12 rows past the header. Post-emit publish: the header's claim
|
||||
// saturates at its own extent; the body receives the remainder in
|
||||
// its own coordinates (boundary minus its start offset).
|
||||
expect(tape(term).length).toBe(22);
|
||||
expect(header.received.at(-1)).toBe(2);
|
||||
expect(Math.max(...header.received)).toBe(2);
|
||||
expect(body.received.at(-1)).toBe(12);
|
||||
// Post-emit freshness holds per child in the multi-child layout:
|
||||
// the body's compose view was still pre-emit, the publish delivered
|
||||
// the advanced count.
|
||||
expect(body.received.at(-1)!).toBeGreaterThan(body.committedRowsAtRender.at(-1)!);
|
||||
|
||||
// The compose-time feed clamps identically: an idle frame restates
|
||||
// each child's saturated claim on every push — never more.
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
expect(header.received.at(-1)).toBe(2);
|
||||
expect(Math.max(...header.received)).toBe(2);
|
||||
expect(body.received.at(-1)).toBe(12);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("never re-anchors a re-laying-out live block mid-run, repairs once at finalize", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const term = new VirtualTerminal(20, 4);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Shared utilities for pi packages",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "16.3.13",
|
||||
"version": "16.3.15",
|
||||
"description": "Shared wire protocol types for Oh My Pi packages",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
Reference in New Issue
Block a user