pi-openai-toolkit 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +55 -33
- package/README.md +65 -8
- package/README_zh.md +63 -8
- package/extensions/codex-astra.ts +1 -0
- package/package.json +9 -2
- package/src/auto-mode/classifier.ts +165 -0
- package/src/auto-mode/extension.ts +530 -273
- package/src/auto-mode/policy.ts +70 -1
- package/src/auto-mode/prompt.ts +279 -0
- package/src/auto-mode/reviewer.ts +224 -138
- package/src/auto-mode/transcript.ts +133 -0
- package/src/auto-mode/types.ts +85 -6
- package/src/codex-astra/effort-planner.ts +175 -0
- package/src/codex-astra/extension.ts +123 -0
- package/src/compact-client.ts +12 -2
- package/src/config.ts +183 -2
- package/src/remote-v2-client.ts +17 -1
- package/src/responses-headers.ts +42 -1
- package/src/runtime.ts +17 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable request-level reasoning effort via `configuration_update` input items
|
|
5
|
+
* (GPT-6 Astra), ported from Oh My Pi (`@oh-my-pi/pi-ai`, MIT licensed,
|
|
6
|
+
* Copyright (c) 2025-2026 Can Bölük / Stencil Labs) to an extension-side
|
|
7
|
+
* payload rewrite.
|
|
8
|
+
*
|
|
9
|
+
* The request-level `reasoning.effort` is pinned to the value of the session's
|
|
10
|
+
* first request so the cached prompt prefix survives an effort change. Each
|
|
11
|
+
* later change is carried as a `configuration_update` item inserted at the
|
|
12
|
+
* tail of the transcript — before the user message it takes effect on, or
|
|
13
|
+
* after the latest tool result when the level changes inside a tool loop — and
|
|
14
|
+
* replayed at that position on every subsequent request until another update
|
|
15
|
+
* overrides it.
|
|
16
|
+
*
|
|
17
|
+
* Wire constraints (verified upstream against the Codex backend): only
|
|
18
|
+
* `gpt-6-astra` accepts the item type, consecutive updates are rejected, and
|
|
19
|
+
* `/responses/compact` rejects histories containing them — compaction
|
|
20
|
+
* requests are built outside this planner and never carry the items.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `configuration_update` input item; only `reasoning.effort` is updatable. */
|
|
24
|
+
export type ConfigurationUpdateItem = {
|
|
25
|
+
type: "configuration_update";
|
|
26
|
+
reasoning: { effort: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PlannableInputItem = {
|
|
30
|
+
type?: string | null;
|
|
31
|
+
role?: string;
|
|
32
|
+
id?: string | null;
|
|
33
|
+
status?: string | null;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type EffortTransition = {
|
|
38
|
+
/** Input-array position the item is spliced into (before `input[index]`). */
|
|
39
|
+
index: number;
|
|
40
|
+
/** Fingerprint of `input[index - 1]` at record time; a mismatch means the history was rewritten. */
|
|
41
|
+
anchor: string;
|
|
42
|
+
effort: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Per-conversation effort baseline and recorded transitions. */
|
|
46
|
+
export type EffortControlState = {
|
|
47
|
+
baseEffort?: string;
|
|
48
|
+
currentEffort?: string;
|
|
49
|
+
transitions: EffortTransition[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type EffortPlanResult = {
|
|
53
|
+
/** The request-level effort that must accompany the returned input. */
|
|
54
|
+
effort: string;
|
|
55
|
+
/** Number of `configuration_update` items spliced into this request. */
|
|
56
|
+
spliced: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createEffortControlState(): EffortControlState {
|
|
60
|
+
return { transitions: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_EFFORT_CONTROL_STATES = 16;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch (or create) the control state for one conversation from a bounded
|
|
67
|
+
* LRU map, refreshing its slot.
|
|
68
|
+
*/
|
|
69
|
+
export function getEffortControlState(states: Map<string, EffortControlState>, key: string): EffortControlState {
|
|
70
|
+
const existing = states.get(key);
|
|
71
|
+
if (existing) {
|
|
72
|
+
states.delete(key);
|
|
73
|
+
states.set(key, existing);
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
const created = createEffortControlState();
|
|
77
|
+
states.set(key, created);
|
|
78
|
+
if (states.size > MAX_EFFORT_CONTROL_STATES) {
|
|
79
|
+
const oldest = states.keys().next().value;
|
|
80
|
+
if (oldest !== undefined) states.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
return created;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fingerprint of the item a transition sits after. Output-only lifecycle
|
|
87
|
+
* fields are excluded: a live response item carries `id`/`status` that the
|
|
88
|
+
* sanitized replay of the same item drops.
|
|
89
|
+
*/
|
|
90
|
+
function transitionAnchor(input: readonly PlannableInputItem[], index: number): string {
|
|
91
|
+
if (index === 0) return "";
|
|
92
|
+
const item = input[index - 1];
|
|
93
|
+
if (!item) return "";
|
|
94
|
+
const { id: _id, status: _status, ...stable } = item;
|
|
95
|
+
return createHash("sha1").update(JSON.stringify(stable)).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resetEffortControlState(state: EffortControlState): void {
|
|
99
|
+
state.baseEffort = undefined;
|
|
100
|
+
state.currentEffort = undefined;
|
|
101
|
+
state.transitions = [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Discard the baseline when the request no longer continues the conversation
|
|
106
|
+
* it was captured for: a wire history that shrank or was rewritten under a
|
|
107
|
+
* recorded transition (compaction, branch switch, `/clear`). The next request
|
|
108
|
+
* re-baselines from its own effort, which is what the API asks for after
|
|
109
|
+
* compaction anyway.
|
|
110
|
+
*/
|
|
111
|
+
function syncEffortControlState(state: EffortControlState, input: readonly PlannableInputItem[]): void {
|
|
112
|
+
for (const transition of state.transitions) {
|
|
113
|
+
if (transition.index > input.length || transition.anchor !== transitionAnchor(input, transition.index)) {
|
|
114
|
+
resetEffortControlState(state);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pin the request-level effort to the session baseline and splice pending
|
|
122
|
+
* `configuration_update` items into a copy of `input`.
|
|
123
|
+
*
|
|
124
|
+
* `input` is the freshly built transcript for this request, without any
|
|
125
|
+
* `configuration_update` items. `requested` is the wire effort the caller
|
|
126
|
+
* would otherwise send at the request level.
|
|
127
|
+
*/
|
|
128
|
+
export function planStableEffort(
|
|
129
|
+
state: EffortControlState,
|
|
130
|
+
input: readonly PlannableInputItem[],
|
|
131
|
+
requested: string,
|
|
132
|
+
): { input: Array<PlannableInputItem | ConfigurationUpdateItem>; result: EffortPlanResult } {
|
|
133
|
+
const next: Array<PlannableInputItem | ConfigurationUpdateItem> = [...input];
|
|
134
|
+
syncEffortControlState(state, next);
|
|
135
|
+
if (state.baseEffort === undefined) {
|
|
136
|
+
state.baseEffort = requested;
|
|
137
|
+
state.currentEffort = requested;
|
|
138
|
+
return { input: next, result: { effort: requested, spliced: 0 } };
|
|
139
|
+
}
|
|
140
|
+
if (state.currentEffort !== requested) {
|
|
141
|
+
const last = next[next.length - 1];
|
|
142
|
+
const index = last && last.role === "user" ? next.length - 1 : next.length;
|
|
143
|
+
const existing = state.transitions.find((transition) => transition.index === index);
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.effort = requested;
|
|
146
|
+
} else {
|
|
147
|
+
state.transitions.push({ index, anchor: transitionAnchor(next, index), effort: requested });
|
|
148
|
+
}
|
|
149
|
+
// A change back to the effort already in force at that position is a
|
|
150
|
+
// no-op on the wire; drop it rather than send a redundant item.
|
|
151
|
+
let preceding = state.baseEffort;
|
|
152
|
+
let precedingIndex = -1;
|
|
153
|
+
for (const transition of state.transitions) {
|
|
154
|
+
if (transition.index < index && transition.index > precedingIndex) {
|
|
155
|
+
preceding = transition.effort;
|
|
156
|
+
precedingIndex = transition.index;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (requested === preceding) {
|
|
160
|
+
state.transitions = state.transitions.filter((transition) => transition.index !== index);
|
|
161
|
+
}
|
|
162
|
+
state.currentEffort = requested;
|
|
163
|
+
}
|
|
164
|
+
// Splice in ascending order so each insertion offsets only the ones after it.
|
|
165
|
+
state.transitions.sort((a, b) => a.index - b.index);
|
|
166
|
+
let offset = 0;
|
|
167
|
+
for (const transition of state.transitions) {
|
|
168
|
+
next.splice(transition.index + offset, 0, {
|
|
169
|
+
type: "configuration_update",
|
|
170
|
+
reasoning: { effort: transition.effort },
|
|
171
|
+
});
|
|
172
|
+
offset++;
|
|
173
|
+
}
|
|
174
|
+
return { input: next, result: { effort: state.baseEffort, spliced: state.transitions.length } };
|
|
175
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadToolkitConfig } from "../config";
|
|
3
|
+
import { isExactModelAllowed } from "../model-scope";
|
|
4
|
+
import { CODEX_CLIENT_VERSION } from "../responses-headers";
|
|
5
|
+
import {
|
|
6
|
+
getEffortControlState,
|
|
7
|
+
planStableEffort,
|
|
8
|
+
type EffortControlState,
|
|
9
|
+
type PlannableInputItem,
|
|
10
|
+
} from "./effort-planner";
|
|
11
|
+
|
|
12
|
+
type AstraPayload = {
|
|
13
|
+
model: string;
|
|
14
|
+
input: PlannableInputItem[];
|
|
15
|
+
reasoning: { effort: string; [key: string]: unknown };
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recognize the live Codex Responses request body: model + wire input array
|
|
25
|
+
* + a request-level string effort. Anything else (titles, embeddings, or a
|
|
26
|
+
* shape we do not understand) is left untouched.
|
|
27
|
+
*/
|
|
28
|
+
function parseAstraPayload(payload: unknown): AstraPayload | undefined {
|
|
29
|
+
if (!isRecord(payload)) return undefined;
|
|
30
|
+
if (typeof payload.model !== "string") return undefined;
|
|
31
|
+
if (!Array.isArray(payload.input)) return undefined;
|
|
32
|
+
const reasoning = payload.reasoning;
|
|
33
|
+
if (!isRecord(reasoning) || typeof reasoning.effort !== "string") return undefined;
|
|
34
|
+
if (reasoning.effort.length === 0 || reasoning.effort === "none") return undefined;
|
|
35
|
+
if (!payload.input.every((item) => isRecord(item))) return undefined;
|
|
36
|
+
return payload as unknown as AstraPayload;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isCompactionShaped(payload: AstraPayload): boolean {
|
|
40
|
+
// A `compaction_trigger` history belongs to a compact endpoint request,
|
|
41
|
+
// which rejects `configuration_update` items. Our compaction clients use
|
|
42
|
+
// direct fetch and never reach this hook; this guard covers any future
|
|
43
|
+
// provider-routed compaction.
|
|
44
|
+
const last = payload.input[payload.input.length - 1];
|
|
45
|
+
return !!last && isRecord(last) && last.type === "compaction_trigger";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSessionId(ctx: { sessionManager: { getSessionId(): string } }): string | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return ctx.sessionManager.getSessionId();
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerCodexAstraExtension(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
59
|
+
): void {
|
|
60
|
+
const states = new Map<string, EffortControlState>();
|
|
61
|
+
|
|
62
|
+
// Switching or forking a session rebuilds the runtime and re-fires
|
|
63
|
+
// session_start; clearing there covers every replacement path.
|
|
64
|
+
pi.on("session_start", () => {
|
|
65
|
+
states.clear();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
69
|
+
try {
|
|
70
|
+
const { config } = loadConfig();
|
|
71
|
+
const astra = config.codexAstra;
|
|
72
|
+
if (!astra.enabled || astra.models.length === 0) return undefined;
|
|
73
|
+
|
|
74
|
+
const model = ctx.model;
|
|
75
|
+
if (!model || model.api !== "openai-codex-responses") return undefined;
|
|
76
|
+
if (!isExactModelAllowed(model, astra.models)) return undefined;
|
|
77
|
+
|
|
78
|
+
const payload = parseAstraPayload(event.payload);
|
|
79
|
+
// The payload must belong to the model the eligibility check just
|
|
80
|
+
// passed; a mismatch means this request is not ours to plan.
|
|
81
|
+
if (!payload || payload.model !== model.id) return undefined;
|
|
82
|
+
if (isCompactionShaped(payload)) return undefined;
|
|
83
|
+
|
|
84
|
+
const sessionId = getSessionId(ctx);
|
|
85
|
+
const state = getEffortControlState(
|
|
86
|
+
states,
|
|
87
|
+
`${model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId ?? "no-session"}`,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Idempotence: never plan on top of items a previous pass injected.
|
|
91
|
+
const history = payload.input.filter((item) => item.type !== "configuration_update");
|
|
92
|
+
const { input, result } = planStableEffort(state, history, payload.reasoning.effort);
|
|
93
|
+
if (result.effort === payload.reasoning.effort && result.spliced === 0) return undefined;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...payload,
|
|
97
|
+
input,
|
|
98
|
+
reasoning: { ...payload.reasoning, effort: result.effort },
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
// A planner failure must never break the provider request path.
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.on("before_provider_headers", (event, ctx) => {
|
|
107
|
+
try {
|
|
108
|
+
const model = ctx.model;
|
|
109
|
+
if (!model || model.api !== "openai-codex-responses") return;
|
|
110
|
+
// Live requests need the same backend version gate as our synthetic
|
|
111
|
+
// ones: an old or missing `version` never reaches gated SKUs.
|
|
112
|
+
if (!event.headers.version) {
|
|
113
|
+
event.headers.version = CODEX_CLIENT_VERSION;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Header injection is best-effort.
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default function codexAstraExtension(pi: ExtensionAPI): void {
|
|
122
|
+
registerCodexAstraExtension(pi);
|
|
123
|
+
}
|
package/src/compact-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeDebugArtifact } from "./debug";
|
|
2
2
|
import { buildResponsesRequestHeaders } from "./responses-headers";
|
|
3
|
+
import { stripConfigurationUpdateItems } from "./remote-v2-client";
|
|
3
4
|
import type { NativeCompactionRuntime } from "./runtime";
|
|
4
5
|
import type { NativeCompactionRequestBody } from "./serializer";
|
|
5
6
|
import type { ArtifactContext, CompactionConfig } from "./types";
|
|
@@ -114,7 +115,10 @@ export function extractCompactedSummaryText(output: readonly unknown[]): string
|
|
|
114
115
|
|
|
115
116
|
function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
|
|
116
117
|
return Object.fromEntries(
|
|
117
|
-
buildResponsesRequestHeaders(runtime, {
|
|
118
|
+
buildResponsesRequestHeaders(runtime, {
|
|
119
|
+
accept: JSON_CONTENT_TYPE,
|
|
120
|
+
sessionId: runtime.sessionId,
|
|
121
|
+
}).entries(),
|
|
118
122
|
);
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -133,7 +137,13 @@ function writeCompactArtifact(
|
|
|
133
137
|
export async function executeNativeCompaction(
|
|
134
138
|
options: ExecuteNativeCompactionOptions,
|
|
135
139
|
): Promise<NativeCompactionClientResult> {
|
|
136
|
-
const { runtime,
|
|
140
|
+
const { runtime, signal, settings, context } = options;
|
|
141
|
+
// Same Astra-history rejection as remote v2: `/responses/compact` rejects
|
|
142
|
+
// `configuration_update` items, so a replayed history never carries one.
|
|
143
|
+
const request: NativeCompactionRequestBody = {
|
|
144
|
+
...options.request,
|
|
145
|
+
input: stripConfigurationUpdateItems(options.request.input) as NativeCompactionRequestBody["input"],
|
|
146
|
+
};
|
|
137
147
|
const headers = toHeaders(runtime);
|
|
138
148
|
|
|
139
149
|
if (signal?.aborted) {
|
package/src/config.ts
CHANGED
|
@@ -8,15 +8,28 @@ import {
|
|
|
8
8
|
DEFAULT_AUTO_COMPACTION_CONFIG,
|
|
9
9
|
DEFAULT_AUTO_MODE_CONFIG,
|
|
10
10
|
DEFAULT_IMAGE_GENERATION_CONFIG,
|
|
11
|
+
DEFAULT_CODEX_ASTRA_CONFIG,
|
|
11
12
|
DEFAULT_NATIVE_FALLBACK_CONFIG,
|
|
12
13
|
DEFAULT_WEB_SEARCH_CONFIG,
|
|
13
14
|
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
15
|
+
BREAKER_LIMIT_MAX,
|
|
16
|
+
BREAKER_LIMIT_MIN,
|
|
17
|
+
BREAKER_WINDOW_MAX,
|
|
18
|
+
BREAKER_WINDOW_MIN,
|
|
19
|
+
CLASSIFIER_MAX_LAG_MAX,
|
|
20
|
+
CLASSIFIER_MAX_LAG_MIN,
|
|
21
|
+
DEFAULT_CLASSIFIER_TIMEOUT_MS,
|
|
22
|
+
EVIDENCE_ROUNDS_MAX,
|
|
23
|
+
EVIDENCE_ROUNDS_MIN,
|
|
14
24
|
REVIEWER_TIMEOUT_MAX_MS,
|
|
15
25
|
REVIEWER_TIMEOUT_MIN_MS,
|
|
16
26
|
THINKING_LEVELS,
|
|
17
27
|
TOOLKIT_ID,
|
|
28
|
+
type AutoModeCircuitBreakerConfig,
|
|
29
|
+
type AutoModeClassifierConfig,
|
|
18
30
|
type AutoModeConfig,
|
|
19
31
|
type AutoModeGate,
|
|
32
|
+
type CodexAstraConfig,
|
|
20
33
|
type CompactionConfig,
|
|
21
34
|
type ImageGenerationConfig,
|
|
22
35
|
type LoadedToolkitConfig,
|
|
@@ -28,7 +41,7 @@ import {
|
|
|
28
41
|
export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
|
|
29
42
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
30
43
|
|
|
31
|
-
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode"]);
|
|
44
|
+
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode", "codexAstra"]);
|
|
32
45
|
const COMPACTION_FIELDS = new Set([
|
|
33
46
|
"enabled",
|
|
34
47
|
"allowCompactionContinuityBreak",
|
|
@@ -47,7 +60,22 @@ const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
|
|
|
47
60
|
const AUTO_COMPACTION_FIELDS = new Set(["enabled", "continuation", "unsupportedFallback", "reserveTokens"]);
|
|
48
61
|
const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
|
|
49
62
|
const IMAGE_GENERATION_FIELDS = new Set(["enabled"]);
|
|
50
|
-
const
|
|
63
|
+
const CODEX_ASTRA_FIELDS = new Set(["enabled", "models"]);
|
|
64
|
+
const AUTO_MODE_FIELDS = new Set([
|
|
65
|
+
"enabled",
|
|
66
|
+
"models",
|
|
67
|
+
"reviewerModel",
|
|
68
|
+
"gate",
|
|
69
|
+
"extraTools",
|
|
70
|
+
"timeoutMs",
|
|
71
|
+
"transcript",
|
|
72
|
+
"evidenceTools",
|
|
73
|
+
"maxEvidenceRounds",
|
|
74
|
+
"classifier",
|
|
75
|
+
"circuitBreaker",
|
|
76
|
+
]);
|
|
77
|
+
const AUTO_MODE_CLASSIFIER_FIELDS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
|
|
78
|
+
const AUTO_MODE_BREAKER_FIELDS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
|
|
51
79
|
|
|
52
80
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
81
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -156,6 +184,21 @@ function toNonNegativeInteger(value: unknown, fieldPath: string, warnings: strin
|
|
|
156
184
|
return undefined;
|
|
157
185
|
}
|
|
158
186
|
|
|
187
|
+
function toBoundedInteger(
|
|
188
|
+
value: unknown,
|
|
189
|
+
fieldPath: string,
|
|
190
|
+
warnings: string[],
|
|
191
|
+
min: number,
|
|
192
|
+
max: number,
|
|
193
|
+
): number | undefined {
|
|
194
|
+
if (value === undefined) return undefined;
|
|
195
|
+
if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= min && value <= max) {
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
warnings.push(`Ignoring ${fieldPath}: expected an integer between ${min} and ${max}.`);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
159
202
|
function toSupportedApis(
|
|
160
203
|
value: unknown,
|
|
161
204
|
fieldPath: string,
|
|
@@ -229,10 +272,16 @@ function cloneDefaults(): ToolkitConfig {
|
|
|
229
272
|
models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
|
|
230
273
|
},
|
|
231
274
|
imageGeneration: { ...DEFAULT_IMAGE_GENERATION_CONFIG },
|
|
275
|
+
codexAstra: {
|
|
276
|
+
...DEFAULT_CODEX_ASTRA_CONFIG,
|
|
277
|
+
models: [...DEFAULT_CODEX_ASTRA_CONFIG.models],
|
|
278
|
+
},
|
|
232
279
|
autoMode: {
|
|
233
280
|
...DEFAULT_AUTO_MODE_CONFIG,
|
|
234
281
|
models: [...DEFAULT_AUTO_MODE_CONFIG.models],
|
|
235
282
|
extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
|
|
283
|
+
classifier: { ...DEFAULT_AUTO_MODE_CONFIG.classifier },
|
|
284
|
+
circuitBreaker: { ...DEFAULT_AUTO_MODE_CONFIG.circuitBreaker },
|
|
236
285
|
},
|
|
237
286
|
};
|
|
238
287
|
}
|
|
@@ -375,6 +424,20 @@ function applyImageGenerationConfig(
|
|
|
375
424
|
toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
|
|
376
425
|
}
|
|
377
426
|
|
|
427
|
+
function applyCodexAstraConfig(
|
|
428
|
+
raw: Record<string, unknown>,
|
|
429
|
+
resolved: CodexAstraConfig,
|
|
430
|
+
warnings: string[],
|
|
431
|
+
): void {
|
|
432
|
+
warnUnknownFields(raw, CODEX_ASTRA_FIELDS, "codexAstra", warnings);
|
|
433
|
+
resolved.enabled = toBoolean(raw.enabled, "codexAstra.enabled", warnings) ?? resolved.enabled;
|
|
434
|
+
|
|
435
|
+
const models = toStringList(raw.models, "codexAstra.models", warnings);
|
|
436
|
+
if (models !== undefined) {
|
|
437
|
+
resolved.models = models;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
378
441
|
function applyAutoModeConfig(
|
|
379
442
|
raw: Record<string, unknown>,
|
|
380
443
|
resolved: AutoModeConfig,
|
|
@@ -404,6 +467,116 @@ function applyAutoModeConfig(
|
|
|
404
467
|
if (timeoutMs !== undefined) {
|
|
405
468
|
resolved.timeoutMs = timeoutMs;
|
|
406
469
|
}
|
|
470
|
+
|
|
471
|
+
resolved.transcript = toBoolean(raw.transcript, "autoMode.transcript", warnings) ?? resolved.transcript;
|
|
472
|
+
resolved.evidenceTools =
|
|
473
|
+
toBoolean(raw.evidenceTools, "autoMode.evidenceTools", warnings) ?? resolved.evidenceTools;
|
|
474
|
+
|
|
475
|
+
const maxEvidenceRounds = toBoundedInteger(
|
|
476
|
+
raw.maxEvidenceRounds,
|
|
477
|
+
"autoMode.maxEvidenceRounds",
|
|
478
|
+
warnings,
|
|
479
|
+
EVIDENCE_ROUNDS_MIN,
|
|
480
|
+
EVIDENCE_ROUNDS_MAX,
|
|
481
|
+
);
|
|
482
|
+
if (maxEvidenceRounds !== undefined) {
|
|
483
|
+
resolved.maxEvidenceRounds = maxEvidenceRounds;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (raw.classifier !== undefined) {
|
|
487
|
+
if (isRecord(raw.classifier)) {
|
|
488
|
+
applyAutoModeClassifierConfig(raw.classifier, resolved.classifier, warnings);
|
|
489
|
+
} else {
|
|
490
|
+
warnings.push("Ignoring autoMode.classifier: expected a JSON object.");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (raw.circuitBreaker !== undefined) {
|
|
495
|
+
if (isRecord(raw.circuitBreaker)) {
|
|
496
|
+
applyAutoModeBreakerConfig(raw.circuitBreaker, resolved.circuitBreaker, warnings);
|
|
497
|
+
} else {
|
|
498
|
+
warnings.push("Ignoring autoMode.circuitBreaker: expected a JSON object.");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function applyAutoModeClassifierConfig(
|
|
504
|
+
raw: Record<string, unknown>,
|
|
505
|
+
resolved: AutoModeClassifierConfig,
|
|
506
|
+
warnings: string[],
|
|
507
|
+
): void {
|
|
508
|
+
warnUnknownFields(raw, AUTO_MODE_CLASSIFIER_FIELDS, "autoMode.classifier", warnings);
|
|
509
|
+
resolved.enabled =
|
|
510
|
+
toBoolean(raw.enabled, "autoMode.classifier.enabled", warnings) ?? resolved.enabled;
|
|
511
|
+
|
|
512
|
+
const model = toModelSpec(raw.model, "autoMode.classifier.model", warnings);
|
|
513
|
+
if (model !== undefined) {
|
|
514
|
+
resolved.model = model === null ? undefined : model;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const timeoutMs =
|
|
518
|
+
toBoundedInteger(
|
|
519
|
+
raw.timeoutMs,
|
|
520
|
+
"autoMode.classifier.timeoutMs",
|
|
521
|
+
warnings,
|
|
522
|
+
REVIEWER_TIMEOUT_MIN_MS,
|
|
523
|
+
REVIEWER_TIMEOUT_MAX_MS,
|
|
524
|
+
) ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
|
|
525
|
+
if (raw.timeoutMs !== undefined) {
|
|
526
|
+
resolved.timeoutMs = timeoutMs;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const maxLag = toBoundedInteger(
|
|
530
|
+
raw.maxLag,
|
|
531
|
+
"autoMode.classifier.maxLag",
|
|
532
|
+
warnings,
|
|
533
|
+
CLASSIFIER_MAX_LAG_MIN,
|
|
534
|
+
CLASSIFIER_MAX_LAG_MAX,
|
|
535
|
+
);
|
|
536
|
+
if (maxLag !== undefined) {
|
|
537
|
+
resolved.maxLag = maxLag;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function applyAutoModeBreakerConfig(
|
|
542
|
+
raw: Record<string, unknown>,
|
|
543
|
+
resolved: AutoModeCircuitBreakerConfig,
|
|
544
|
+
warnings: string[],
|
|
545
|
+
): void {
|
|
546
|
+
warnUnknownFields(raw, AUTO_MODE_BREAKER_FIELDS, "autoMode.circuitBreaker", warnings);
|
|
547
|
+
|
|
548
|
+
const consecutiveDenials = toBoundedInteger(
|
|
549
|
+
raw.consecutiveDenials,
|
|
550
|
+
"autoMode.circuitBreaker.consecutiveDenials",
|
|
551
|
+
warnings,
|
|
552
|
+
BREAKER_LIMIT_MIN,
|
|
553
|
+
BREAKER_LIMIT_MAX,
|
|
554
|
+
);
|
|
555
|
+
if (consecutiveDenials !== undefined) {
|
|
556
|
+
resolved.consecutiveDenials = consecutiveDenials;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const recentDenials = toBoundedInteger(
|
|
560
|
+
raw.recentDenials,
|
|
561
|
+
"autoMode.circuitBreaker.recentDenials",
|
|
562
|
+
warnings,
|
|
563
|
+
BREAKER_LIMIT_MIN,
|
|
564
|
+
BREAKER_LIMIT_MAX,
|
|
565
|
+
);
|
|
566
|
+
if (recentDenials !== undefined) {
|
|
567
|
+
resolved.recentDenials = recentDenials;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const windowSize = toBoundedInteger(
|
|
571
|
+
raw.windowSize,
|
|
572
|
+
"autoMode.circuitBreaker.windowSize",
|
|
573
|
+
warnings,
|
|
574
|
+
BREAKER_WINDOW_MIN,
|
|
575
|
+
BREAKER_WINDOW_MAX,
|
|
576
|
+
);
|
|
577
|
+
if (windowSize !== undefined) {
|
|
578
|
+
resolved.windowSize = windowSize;
|
|
579
|
+
}
|
|
407
580
|
}
|
|
408
581
|
|
|
409
582
|
/**
|
|
@@ -452,6 +625,14 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
|
|
|
452
625
|
warnings.push("Ignoring autoMode: expected a JSON object.");
|
|
453
626
|
}
|
|
454
627
|
}
|
|
628
|
+
|
|
629
|
+
if (raw.codexAstra !== undefined) {
|
|
630
|
+
if (isRecord(raw.codexAstra)) {
|
|
631
|
+
applyCodexAstraConfig(raw.codexAstra, resolved.codexAstra, warnings);
|
|
632
|
+
} else {
|
|
633
|
+
warnings.push("Ignoring codexAstra: expected a JSON object.");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
455
636
|
}
|
|
456
637
|
|
|
457
638
|
resolved.compaction.artifactRoot = resolveConfiguredPath(
|
package/src/remote-v2-client.ts
CHANGED
|
@@ -119,16 +119,32 @@ function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
|
|
|
119
119
|
buildResponsesRequestHeaders(runtime, {
|
|
120
120
|
accept: SSE_CONTENT_TYPE,
|
|
121
121
|
contentType: JSON_CONTENT_TYPE,
|
|
122
|
+
sessionId: runtime.sessionId,
|
|
122
123
|
}).entries(),
|
|
123
124
|
);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Codex `/responses` rejects histories that mix this Astra-only item into a
|
|
129
|
+
* compaction request (verified upstream in Oh My Pi: the compaction path is
|
|
130
|
+
* deliberately built outside the effort planner). The host never emits the
|
|
131
|
+
* item into a payload we would replay today, but the item is transparently
|
|
132
|
+
* carried by our opaque-window replay, so strip it defensively on every
|
|
133
|
+
* remote v2 request regardless of API family.
|
|
134
|
+
*/
|
|
135
|
+
export function stripConfigurationUpdateItems(input: readonly unknown[]): unknown[] {
|
|
136
|
+
return input.filter((item) => !isRecord(item) || item["type"] !== "configuration_update");
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
export function buildRemoteV2CompactionRequest(
|
|
127
140
|
request: NativeCompactionRequestBody,
|
|
128
141
|
): RemoteV2CompactionRequestBody {
|
|
129
142
|
return {
|
|
130
143
|
...structuredClone(request),
|
|
131
|
-
input: [
|
|
144
|
+
input: [
|
|
145
|
+
...(stripConfigurationUpdateItems(structuredClone(request.input)) as ResponsesInputItem[]),
|
|
146
|
+
{ type: "compaction_trigger" },
|
|
147
|
+
],
|
|
132
148
|
store: false,
|
|
133
149
|
stream: true,
|
|
134
150
|
};
|
package/src/responses-headers.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type ResponsesHeaderRuntime = {
|
|
|
5
5
|
api: string;
|
|
6
6
|
apiKey: string;
|
|
7
7
|
headers?: ProviderHeaders;
|
|
8
|
+
sessionId?: string;
|
|
8
9
|
currentModel: {
|
|
9
10
|
headers?: ProviderHeaders;
|
|
10
11
|
};
|
|
@@ -38,9 +39,34 @@ function buildCodexUserAgent(): string {
|
|
|
38
39
|
return `pi (${platform}; ${arch})`;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The Codex backend version-gates model availability on the `version` header
|
|
44
|
+
* (the pinned @openai/codex client version). `gpt-6-astra` requires >= 0.153.0;
|
|
45
|
+
* an older or absent pin makes the SKU invisible to discovery and rejects
|
|
46
|
+
* requests that name it. Keep this in sync with the newest gate Oh My Pi pins
|
|
47
|
+
* in @oh-my-pi/pi-wire/codex (`CODEX_CLIENT_VERSION`).
|
|
48
|
+
*/
|
|
49
|
+
export const CODEX_CLIENT_VERSION = "0.153.0";
|
|
50
|
+
|
|
51
|
+
/** Prompt cache keys are clamped by the same host rule (`OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH`). */
|
|
52
|
+
const CODEX_SESSION_ID_MAX_LENGTH = 64;
|
|
53
|
+
|
|
54
|
+
export function clampCodexSessionId(sessionId: string | undefined): string | undefined {
|
|
55
|
+
if (!sessionId) return undefined;
|
|
56
|
+
const chars = Array.from(sessionId);
|
|
57
|
+
return chars.length <= CODEX_SESSION_ID_MAX_LENGTH ? sessionId : chars.slice(0, CODEX_SESSION_ID_MAX_LENGTH).join("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ResponsesHeaderOptions = {
|
|
61
|
+
accept: string;
|
|
62
|
+
contentType?: string;
|
|
63
|
+
/** Conversation identity for Codex cache-affinity headers; clamped to 64 chars here. */
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
41
67
|
export function buildResponsesRequestHeaders(
|
|
42
68
|
runtime: ResponsesHeaderRuntime,
|
|
43
|
-
options:
|
|
69
|
+
options: ResponsesHeaderOptions,
|
|
44
70
|
): Headers {
|
|
45
71
|
const headers = new Headers(
|
|
46
72
|
mergeProviderHeaders(runtime.currentModel.headers, runtime.headers),
|
|
@@ -57,6 +83,21 @@ export function buildResponsesRequestHeaders(
|
|
|
57
83
|
headers.set("originator", "pi");
|
|
58
84
|
headers.set("user-agent", buildCodexUserAgent());
|
|
59
85
|
headers.set("openai-beta", "responses=experimental");
|
|
86
|
+
// Codex backend gating: a request without a new-enough `version` never
|
|
87
|
+
// reaches model SKUs that arrived behind the gate (gpt-6-astra).
|
|
88
|
+
headers.set("version", CODEX_CLIENT_VERSION);
|
|
89
|
+
// Prompt-cache affinity rides on the conversation identity: the backend
|
|
90
|
+
// keeps a conversation's cache prefix on the node that wrote it, so our
|
|
91
|
+
// synthetic compaction requests must present the same id the live
|
|
92
|
+
// transcript requests used, or every compact pays a full cache miss.
|
|
93
|
+
const sessionId = clampCodexSessionId(options.sessionId ?? runtime.sessionId);
|
|
94
|
+
if (sessionId) {
|
|
95
|
+
// Header names match the installed host's live-request builder
|
|
96
|
+
// (`buildSSEHeaders`: `session-id` + `x-client-request-id`) so our
|
|
97
|
+
// synthetic requests land in the same conversation affinity group.
|
|
98
|
+
headers.set("session-id", sessionId);
|
|
99
|
+
headers.set("x-client-request-id", sessionId);
|
|
100
|
+
}
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
return headers;
|