pi-openai-toolkit 0.22.3 → 0.23.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.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +4 -2
  4. package/README.zh.md +4 -2
  5. package/config.schema.json +278 -278
  6. package/docs/configuration.md +298 -298
  7. package/docs/internals.md +181 -181
  8. package/package.json +134 -134
  9. package/src/compact-client.ts +412 -412
  10. package/src/compaction-projection.ts +35 -35
  11. package/src/config/migration.ts +103 -103
  12. package/src/config.ts +310 -310
  13. package/src/context-management/codex-provider.ts +270 -270
  14. package/src/context-management/history-notes.ts +357 -357
  15. package/src/context-management/manual-compact.ts +181 -4
  16. package/src/context-management/tools.ts +371 -371
  17. package/src/context-management/types.ts +168 -168
  18. package/src/context-management/window-manager.ts +905 -811
  19. package/src/debug.ts +238 -238
  20. package/src/deferred-tool-carryover.ts +282 -282
  21. package/src/details-store.ts +151 -151
  22. package/src/extension-runtime.ts +1986 -1661
  23. package/src/image-generation/extension.ts +189 -189
  24. package/src/image-generation/protocol.ts +579 -579
  25. package/src/image-generation/types.ts +300 -300
  26. package/src/payload-rewrite.ts +515 -515
  27. package/src/pi-context-hook.ts +117 -117
  28. package/src/remote-v2-client.ts +811 -811
  29. package/src/request-context-cache.ts +155 -155
  30. package/src/responses-headers.ts +180 -180
  31. package/src/runtime.ts +865 -865
  32. package/src/serializer.ts +882 -882
  33. package/src/supported-environment.ts +27 -27
  34. package/src/types.ts +565 -565
  35. package/src/web-search/alpha-client.ts +876 -876
  36. package/src/web-search/extension.ts +530 -530
  37. package/src/web-search/payload.ts +299 -299
  38. package/src/web-search/prompt.ts +60 -60
  39. package/src/web-search/types.ts +166 -166
  40. package/tsconfig.check.json +50 -50
package/src/runtime.ts CHANGED
@@ -1,865 +1,865 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { Api, Message, Model, ModelThinkingLevel, ProviderHeaders } from "@earendil-works/pi-ai";
3
- import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { isExactModelAllowed } from "./model-scope";
5
- import {
6
- CODEX_AFFINITY_SCOPE,
7
- buildResponsesRequestHeaders,
8
- type CodexAffinity,
9
- } from "./responses-headers";
10
- import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
11
-
12
- const OPENAI_RESPONSES_PATH = "responses";
13
- const CODEX_RESPONSES_PATH = "codex/responses";
14
- const AZURE_RESPONSES_PATH = "responses";
15
- const OPENAI_COMPACT_PATH = "responses/compact";
16
- const CODEX_COMPACT_PATH = "codex/responses/compact";
17
- const AZURE_COMPACT_PATH = "responses/compact";
18
- const AZURE_DEFAULT_API_VERSION = "v1";
19
- const ALPHA_SEARCH_PATH = "alpha/search";
20
- export const VIRTUAL_MODEL_API = "pi-virtual" as const;
21
-
22
- export type ResponsesApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
23
-
24
- type ResponsesCompactApi = ResponsesApi;
25
- type RuntimeModel = Model<Api>;
26
-
27
- export type ParsedModelSpec = {
28
- provider: string;
29
- modelId: string;
30
- };
31
-
32
- export type NativeCompactionFailureReason =
33
- | "disabled"
34
- | "missing-model"
35
- | "virtual-model-unresolved"
36
- | "invalid-model-spec"
37
- | "model-not-found"
38
- | "unsupported-api"
39
- | "missing-base-url"
40
- | "invalid-base-url"
41
- | "missing-session-id"
42
- | "missing-api-key"
43
- | "auth-resolution-failed"
44
- | "unsupported-payload"
45
- | "payload-model-mismatch"
46
- | "base-url-mismatch";
47
-
48
- export type NativeCompactionSupportOptions = {
49
- enabled?: boolean;
50
- /** Which Responses APIs should use the compact endpoint; defaults to all capable APIs. */
51
- responsesApis?: readonly string[];
52
- /** Exact provider/model keys whose Responses traffic carries gateway Codex affinity metadata. */
53
- codexGatewayModels?: readonly string[];
54
- };
55
-
56
- export type ResponsesSupportOptions = NativeCompactionSupportOptions;
57
-
58
- export type ResponsesCompatibleRequestPayload = {
59
- model: string;
60
- input: unknown[];
61
- instructions?: unknown;
62
- [key: string]: unknown;
63
- };
64
-
65
- export type ResponsesRuntime = {
66
- provider: string;
67
- api: ResponsesApi;
68
- /** Logical Pi model id. */
69
- model: string;
70
- /** Wire model/deployment id; usually the same as `model`. */
71
- requestModel?: string;
72
- baseUrl: string;
73
- apiKey: string;
74
- env?: Record<string, string>;
75
- headers?: ProviderHeaders;
76
- responsesPath: string;
77
- responsesUrl: string;
78
- /**
79
- * Conversation identity for Codex prompt-cache affinity headers. Synthetic
80
- * compaction/review requests must present the same id the live transcript
81
- * requests used or the backend treats them as a new conversation.
82
- */
83
- sessionId?: string;
84
- /** Opt-in gateway Codex routing metadata for synthetic Responses/compact requests. */
85
- codexAffinity?: CodexAffinity;
86
- currentModel: RuntimeModel;
87
- };
88
-
89
- export type NativeCompactionRuntime = ResponsesRuntime & {
90
- /** Public host snapshot for the resolved physical model; never inferred from token text. */
91
- authMode?: "oauth" | "non-oauth" | "unknown";
92
- compactPath: string;
93
- compactUrl: string;
94
- payload?: ResponsesCompatibleRequestPayload;
95
- };
96
-
97
- export type NativeCompactionEnvironmentFailure = {
98
- ok: false;
99
- reason: NativeCompactionFailureReason;
100
- provider?: string;
101
- api?: string;
102
- model?: string;
103
- baseUrl?: string;
104
- modelSpec?: string;
105
- errorMessage?: string;
106
- };
107
-
108
- export type NativeCompactionEnvironmentSuccess = {
109
- ok: true;
110
- runtime: NativeCompactionRuntime;
111
- };
112
-
113
- export type NativeCompactionEnvironmentResolution =
114
- | NativeCompactionEnvironmentFailure
115
- | NativeCompactionEnvironmentSuccess;
116
-
117
- export type ResponsesEnvironmentSuccess = {
118
- ok: true;
119
- runtime: ResponsesRuntime;
120
- };
121
-
122
- export type ResponsesEnvironmentResolution =
123
- | NativeCompactionEnvironmentFailure
124
- | ResponsesEnvironmentSuccess;
125
-
126
- export type RemoteCompactionExecution = {
127
- /** Active session model. Its identity owns replay matching and is never mutated. */
128
- consumer: NativeCompactionRuntime;
129
- /** Model used only for the synthetic remote_compaction_v2 request. */
130
- compactor: NativeCompactionRuntime;
131
- };
132
-
133
- export type RemoteCompactionExecutionSuccess = {
134
- ok: true;
135
- execution: RemoteCompactionExecution;
136
- };
137
-
138
- export type RemoteCompactionExecutionResolution =
139
- | NativeCompactionEnvironmentFailure
140
- | RemoteCompactionExecutionSuccess;
141
-
142
- type ResolvedRequestAuth =
143
- | {
144
- ok: true;
145
- apiKey?: string;
146
- headers?: ProviderHeaders;
147
- baseUrl?: string;
148
- env?: Record<string, string>;
149
- }
150
- | { ok: false; error: string };
151
-
152
- type VirtualModelRuntime = {
153
- resolveModel?: (
154
- model: RuntimeModel,
155
- messages: readonly Message[],
156
- options: {
157
- reason: "direct";
158
- thinkingLevel: ModelThinkingLevel;
159
- signal?: AbortSignal;
160
- state?: unknown;
161
- },
162
- ) => Promise<{ model: RuntimeModel; thinkingLevel: ModelThinkingLevel; state?: unknown }>;
163
- getPhysicalModel?: (provider: string, modelId: string) => RuntimeModel | undefined;
164
- };
165
-
166
- type ModelRegistryRuntimeBridge = {
167
- runtime?: VirtualModelRuntime;
168
- };
169
-
170
- export type EffectiveModelResolution =
171
- | { ok: true; model: RuntimeModel; virtual: boolean }
172
- | { ok: false; reason: "missing-model" | "virtual-model-unresolved"; errorMessage?: string };
173
-
174
- const physicalModelCache = new WeakMap<object, Map<string, RuntimeModel>>();
175
- const physicalModelObserverApis = new WeakSet<object>();
176
-
177
- function modelKey(model: RuntimeModel): string {
178
- return `${model.provider}/${model.id}`;
179
- }
180
-
181
- function getPhysicalModelCache(ctx: ExtensionContext): Map<string, RuntimeModel> {
182
- const registry = ctx.modelRegistry as object;
183
- let cache = physicalModelCache.get(registry);
184
- if (!cache) {
185
- cache = new Map<string, RuntimeModel>();
186
- physicalModelCache.set(registry, cache);
187
- }
188
- return cache;
189
- }
190
-
191
- function sessionModelKey(ctx: ExtensionContext, model: RuntimeModel): string | undefined {
192
- try {
193
- const sessionId = ctx.sessionManager.getSessionId();
194
- return sessionId ? `${sessionId}\u0000${modelKey(model)}` : undefined;
195
- } catch {
196
- return undefined;
197
- }
198
- }
199
-
200
- function isAssistantModelMessage(message: AgentMessage): message is AgentMessage & {
201
- provider: string;
202
- api: string;
203
- model: string;
204
- } {
205
- if (message.role !== "assistant") return false;
206
- const candidate = message as unknown as Record<string, unknown>;
207
- return typeof candidate.provider === "string" &&
208
- typeof candidate.api === "string" &&
209
- typeof candidate.model === "string";
210
- }
211
-
212
- /** Remember the physical model Pi actually used as a fallback for a virtual selection. */
213
- export function rememberPhysicalModel(ctx: ExtensionContext, message: AgentMessage): void {
214
- const selected = ctx.model as RuntimeModel | undefined;
215
- if (!selected || selected.api !== VIRTUAL_MODEL_API || !isAssistantModelMessage(message)) return;
216
- const physical = ctx.modelRegistry.find(message.provider, message.model);
217
- const key = sessionModelKey(ctx, selected);
218
- if (physical && physical.api !== VIRTUAL_MODEL_API && key) {
219
- getPhysicalModelCache(ctx).set(key, physical);
220
- }
221
- }
222
-
223
- /**
224
- * Register the fallback cache observer used by feature extensions. Pi 0.99
225
- * exposes a resolver for fresh routes; the assistant message cache only helps
226
- * when a host cannot expose that resolver after a physical response exists.
227
- */
228
- export function registerPhysicalModelObserver(pi: ExtensionAPI): void {
229
- if (physicalModelObserverApis.has(pi)) return;
230
- physicalModelObserverApis.add(pi);
231
- pi.on("message_end", (event, ctx) => {
232
- rememberPhysicalModel(ctx, event.message);
233
- });
234
- }
235
-
236
- async function resolveVirtualModel(ctx: ExtensionContext, model: RuntimeModel): Promise<EffectiveModelResolution> {
237
- const cache = getPhysicalModelCache(ctx);
238
- const key = sessionModelKey(ctx, model);
239
- const bridge = ctx.modelRegistry as unknown as ModelRegistryRuntimeBridge;
240
- const runtime = bridge.runtime;
241
- if (typeof runtime?.resolveModel !== "function") {
242
- const cached = key ? cache.get(key) : undefined;
243
- if (cached) return { ok: true, model: cached, virtual: true };
244
- return {
245
- ok: false,
246
- reason: "virtual-model-unresolved",
247
- errorMessage: "Pi does not expose virtual-model routing to extensions.",
248
- };
249
- }
250
-
251
- let messages: Message[];
252
- try {
253
- messages = convertToLlm(ctx.sessionManager.buildSessionProjection().messages);
254
- } catch (error) {
255
- return {
256
- ok: false,
257
- reason: "virtual-model-unresolved",
258
- errorMessage: error instanceof Error ? error.message : String(error),
259
- };
260
- }
261
-
262
- try {
263
- const route = await runtime.resolveModel(model, messages, {
264
- reason: "direct",
265
- thinkingLevel: (ctx.thinkingLevel ?? "off") as ModelThinkingLevel,
266
- signal: ctx.signal,
267
- });
268
- const physical = runtime.getPhysicalModel?.(route.model.provider, route.model.id)
269
- ?? ctx.modelRegistry.find(route.model.provider, route.model.id)
270
- ?? route.model;
271
- if (!physical || physical.api === VIRTUAL_MODEL_API) {
272
- return {
273
- ok: false,
274
- reason: "virtual-model-unresolved",
275
- errorMessage: "Pi virtual-model routing did not return a physical model.",
276
- };
277
- }
278
- if (key) cache.set(key, physical);
279
- return { ok: true, model: physical, virtual: true };
280
- } catch (error) {
281
- return {
282
- ok: false,
283
- reason: "virtual-model-unresolved",
284
- errorMessage: error instanceof Error ? error.message : String(error),
285
- };
286
- }
287
- }
288
-
289
- export async function resolveEffectiveModel(
290
- ctx: ExtensionContext,
291
- model: ExtensionContext["model"] = ctx.model,
292
- ): Promise<EffectiveModelResolution> {
293
- if (!model) return { ok: false, reason: "missing-model" };
294
- const runtimeModel = model as RuntimeModel;
295
- if (runtimeModel.api !== VIRTUAL_MODEL_API) return { ok: true, model: runtimeModel, virtual: false };
296
- return resolveVirtualModel(ctx, runtimeModel);
297
- }
298
-
299
- function normalizeConfiguredApis(values: readonly string[] | undefined): Set<string> {
300
- if (values === undefined) {
301
- return new Set(RESPONSES_COMPACT_CAPABLE_APIS);
302
- }
303
- return new Set(values.map((value) => value.trim()).filter((value) => value.length > 0));
304
- }
305
-
306
- function resolveCodexGatewayAffinity(
307
- model: RuntimeModel,
308
- gatewayModels: readonly string[] | undefined,
309
- logicalModel?: RuntimeModel,
310
- ): CodexAffinity | undefined {
311
- const allowlist = gatewayModels ?? [];
312
- const allowed = isExactModelAllowed(model, allowlist) ||
313
- (logicalModel?.api === VIRTUAL_MODEL_API && isExactModelAllowed(logicalModel, allowlist));
314
- if (model.api !== "openai-responses" || !allowed) {
315
- return undefined;
316
- }
317
- return {
318
- model: model.id,
319
- scope: CODEX_AFFINITY_SCOPE,
320
- };
321
- }
322
-
323
- /** Parse "provider/model-id" (model ids may themselves contain slashes). */
324
- export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
325
- const trimmed = spec.trim();
326
- const separatorIndex = trimmed.indexOf("/");
327
- if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
328
- return undefined;
329
- }
330
-
331
- const provider = trimmed.slice(0, separatorIndex).trim();
332
- const modelId = trimmed.slice(separatorIndex + 1).trim();
333
- if (!provider || !modelId) {
334
- return undefined;
335
- }
336
-
337
- return { provider, modelId };
338
- }
339
-
340
- export function normalizeBaseUrl(baseUrl: string | undefined | null): string | undefined {
341
- const normalized = baseUrl?.trim().replace(/\/+$/, "");
342
- return normalized ? normalized : undefined;
343
- }
344
-
345
- function buildOpenAIResponsesUrl(baseUrl: string): string {
346
- const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
347
- return normalized.endsWith("/responses") ? normalized : `${normalized}/${OPENAI_RESPONSES_PATH}`;
348
- }
349
-
350
- function buildCodexResponsesUrl(baseUrl: string): string {
351
- const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
352
- if (normalized.endsWith("/codex/responses")) {
353
- return normalized;
354
- }
355
- if (normalized.endsWith("/codex")) {
356
- return `${normalized}/responses`;
357
- }
358
- return `${normalized}/${CODEX_RESPONSES_PATH}`;
359
- }
360
-
361
- function buildOpenAICompactUrl(baseUrl: string): string {
362
- return `${buildOpenAIResponsesUrl(baseUrl)}/compact`;
363
- }
364
-
365
- function buildCodexCompactUrl(baseUrl: string): string {
366
- return `${buildCodexResponsesUrl(baseUrl)}/compact`;
367
- }
368
-
369
- function envValue(env: Record<string, string> | undefined, name: string): string | undefined {
370
- const value = env?.[name]?.trim();
371
- return value || undefined;
372
- }
373
-
374
- function buildAzureBaseUrl(baseUrl: string | undefined, env: Record<string, string> | undefined): string | undefined {
375
- const configured = envValue(env, "AZURE_OPENAI_BASE_URL") ?? normalizeBaseUrl(baseUrl);
376
- if (configured) return configured;
377
- const resource = envValue(env, "AZURE_OPENAI_RESOURCE_NAME");
378
- return resource ? `https://${resource}.openai.azure.com/openai/v1` : undefined;
379
- }
380
-
381
- function normalizeAzureBaseUrl(baseUrl: string): string {
382
- const url = new URL(baseUrl);
383
- const pathname = url.pathname.replace(/\/+$/, "");
384
- if (pathname === "" || pathname === "/" || pathname === "/openai") url.pathname = "/openai/v1";
385
- else if (pathname.endsWith("/responses")) url.pathname = pathname.slice(0, -"/responses".length);
386
- url.search = "";
387
- url.hash = "";
388
- return url.toString().replace(/\/$/, "");
389
- }
390
-
391
- function appendAzureApiVersion(urlValue: string, env: Record<string, string> | undefined): string {
392
- const url = new URL(urlValue);
393
- if (!url.searchParams.has("api-version")) {
394
- url.searchParams.set("api-version", envValue(env, "AZURE_OPENAI_API_VERSION") ?? AZURE_DEFAULT_API_VERSION);
395
- }
396
- return url.toString();
397
- }
398
-
399
- export function resolveAzureDeploymentName(modelId: string, env: Record<string, string> | undefined): string {
400
- const mapping = envValue(env, "AZURE_OPENAI_DEPLOYMENT_NAME_MAP");
401
- if (mapping) {
402
- for (const entry of mapping.split(",")) {
403
- const separator = entry.indexOf("=");
404
- if (separator <= 0) continue;
405
- if (entry.slice(0, separator).trim() === modelId) {
406
- const deployment = entry.slice(separator + 1).trim();
407
- if (deployment) return deployment;
408
- }
409
- }
410
- }
411
- return modelId;
412
- }
413
-
414
- export function buildResponsesUrl(
415
- baseUrl: string,
416
- api: ResponsesCompactApi,
417
- env?: Record<string, string>,
418
- ): string {
419
- if (api === "openai-codex-responses") return buildCodexResponsesUrl(baseUrl);
420
- if (api === "azure-openai-responses") {
421
- return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_RESPONSES_PATH}`, env);
422
- }
423
- return buildOpenAIResponsesUrl(baseUrl);
424
- }
425
-
426
- export function buildResponsesPath(api: ResponsesCompactApi): string {
427
- return api === "openai-codex-responses" ? CODEX_RESPONSES_PATH : AZURE_RESPONSES_PATH;
428
- }
429
-
430
- export function buildCompactUrl(
431
- baseUrl: string,
432
- api: ResponsesCompactApi,
433
- env?: Record<string, string>,
434
- ): string {
435
- if (api === "openai-codex-responses") return buildCodexCompactUrl(baseUrl);
436
- if (api === "azure-openai-responses") {
437
- return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_COMPACT_PATH}`, env);
438
- }
439
- return buildOpenAICompactUrl(baseUrl);
440
- }
441
-
442
- export function buildCompactPath(api: ResponsesCompactApi): string {
443
- return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : AZURE_COMPACT_PATH;
444
- }
445
-
446
- /**
447
- * Build the provider-relative standalone-search endpoint. This deliberately
448
- * rejects a full Responses endpoint so an already-resolved URL cannot become
449
- * `/responses/alpha/search` or `/codex/responses/alpha/search` by accident.
450
- */
451
- export function buildAlphaSearchUrl(baseUrl: string): string | undefined {
452
- const normalized = normalizeBaseUrl(baseUrl);
453
- if (!normalized) return undefined;
454
-
455
- let parsed: URL;
456
- try {
457
- parsed = new URL(normalized);
458
- } catch {
459
- return undefined;
460
- }
461
- if (
462
- (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
463
- parsed.username ||
464
- parsed.password ||
465
- parsed.search ||
466
- parsed.hash
467
- ) {
468
- return undefined;
469
- }
470
-
471
- const pathname = parsed.pathname.replace(/\/+$/, "");
472
- if (
473
- pathname.endsWith("/responses/alpha/search") ||
474
- pathname.endsWith("/codex/responses/alpha/search") ||
475
- pathname.endsWith("/responses/compact/alpha/search") ||
476
- pathname.endsWith("/codex/responses/compact/alpha/search")
477
- ) {
478
- return undefined;
479
- }
480
- if (pathname.endsWith("/alpha/search")) {
481
- parsed.pathname = pathname;
482
- return parsed.toString();
483
- }
484
- if (
485
- pathname.endsWith("/responses") ||
486
- pathname.endsWith("/codex/responses") ||
487
- pathname.endsWith("/responses/compact") ||
488
- pathname.endsWith("/codex/responses/compact")
489
- ) {
490
- return undefined;
491
- }
492
-
493
- parsed.pathname = `${pathname}/${ALPHA_SEARCH_PATH}`.replace(/^\/\//, "/");
494
- return parsed.toString();
495
- }
496
-
497
- async function resolveRequestAuth(ctx: ExtensionContext, model: RuntimeModel): Promise<ResolvedRequestAuth> {
498
- const modelRegistry = ctx.modelRegistry as {
499
- getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<ResolvedRequestAuth>;
500
- };
501
-
502
- if (typeof modelRegistry.getApiKeyAndHeaders !== "function") {
503
- return { ok: true };
504
- }
505
-
506
- return modelRegistry.getApiKeyAndHeaders(model);
507
- }
508
-
509
- function resolveAuthMode(ctx: ExtensionContext, model: RuntimeModel): NativeCompactionRuntime["authMode"] {
510
- const registry = ctx.modelRegistry as unknown as {
511
- isUsingOAuth?: (model: RuntimeModel) => unknown;
512
- hasConfiguredAuth?: (model: RuntimeModel) => unknown;
513
- };
514
- try {
515
- if (typeof registry.isUsingOAuth !== "function" || typeof registry.hasConfiguredAuth !== "function" ||
516
- registry.hasConfiguredAuth(model) !== true) return "unknown";
517
- const oauth = registry.isUsingOAuth(model);
518
- return oauth === true ? "oauth" : oauth === false ? "non-oauth" : "unknown";
519
- } catch {
520
- return "unknown";
521
- }
522
- }
523
-
524
- /** Conservative automatic standalone routing; provider aliases alone prove nothing. */
525
- export function isStandaloneCompactionEligible(runtime: NativeCompactionRuntime): boolean {
526
- if (runtime.api !== "openai-responses" || runtime.codexAffinity || runtime.authMode !== "non-oauth") return false;
527
- try {
528
- const url = new URL(runtime.compactUrl);
529
- if (url.protocol !== "https:" || url.hostname !== "api.openai.com" || url.port ||
530
- url.pathname !== "/v1/responses/compact" || url.username || url.password || url.search || url.hash) return false;
531
- const headers = buildResponsesRequestHeaders(runtime, { accept: "application/json" });
532
- return headers.get("authorization") === `Bearer ${runtime.apiKey}`;
533
- } catch {
534
- return false;
535
- }
536
- }
537
-
538
- export function isSupportedApi(api: string): api is ResponsesCompactApi {
539
- return (RESPONSES_COMPACT_CAPABLE_APIS as readonly string[]).includes(api);
540
- }
541
-
542
- export function isResponsesCompatiblePayload(payload: unknown): payload is ResponsesCompatibleRequestPayload {
543
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
544
- return false;
545
- }
546
-
547
- const candidate = payload as Record<string, unknown>;
548
- return typeof candidate.model === "string" && Array.isArray(candidate.input);
549
- }
550
-
551
- export function getRuntimeModelDescriptor(model: RuntimeModel | undefined): {
552
- provider?: string;
553
- api?: string;
554
- model?: string;
555
- baseUrl?: string;
556
- } {
557
- if (!model) {
558
- return {};
559
- }
560
-
561
- return {
562
- provider: model.provider,
563
- api: model.api,
564
- model: model.id,
565
- baseUrl: normalizeBaseUrl(model.baseUrl),
566
- };
567
- }
568
-
569
- /** Best-effort conversation id; header emission then simply skips the affinity headers. */
570
- export function resolveRuntimeSessionId(ctx: ExtensionContext): string | undefined {
571
- try {
572
- return ctx.sessionManager.getSessionId();
573
- } catch {
574
- return undefined;
575
- }
576
- }
577
-
578
- async function resolveNativeCompactionEnvironmentForModel(
579
- ctx: ExtensionContext,
580
- currentModel: RuntimeModel | undefined,
581
- options: NativeCompactionSupportOptions,
582
- payload?: unknown,
583
- ): Promise<NativeCompactionEnvironmentResolution> {
584
- if (options.enabled === false) {
585
- return {
586
- ok: false,
587
- reason: "disabled",
588
- };
589
- }
590
-
591
- const originalDescriptor = getRuntimeModelDescriptor(currentModel);
592
- if (!currentModel || !originalDescriptor.provider || !originalDescriptor.api || !originalDescriptor.model) {
593
- return {
594
- ok: false,
595
- reason: "missing-model",
596
- ...originalDescriptor,
597
- };
598
- }
599
-
600
- const effective = await resolveEffectiveModel(ctx, currentModel);
601
- if (!effective.ok) {
602
- return {
603
- ok: false,
604
- reason: effective.reason,
605
- errorMessage: effective.errorMessage,
606
- ...originalDescriptor,
607
- };
608
- }
609
- const effectiveModel = effective.model;
610
- const descriptor = getRuntimeModelDescriptor(effectiveModel);
611
- if (!descriptor.provider || !descriptor.api || !descriptor.model) {
612
- return {
613
- ok: false,
614
- reason: "missing-model",
615
- ...descriptor,
616
- };
617
- }
618
-
619
- // The compact endpoint is selected purely by API family: any provider speaking
620
- // a validated Responses API gets a native compact attempt and fails open (to
621
- // the fallback model or pi's default) when the endpoint is missing.
622
- const configuredApis = normalizeConfiguredApis(options.responsesApis);
623
- if (!configuredApis.has(descriptor.api) || !isSupportedApi(descriptor.api)) {
624
- return {
625
- ok: false,
626
- reason: "unsupported-api",
627
- ...descriptor,
628
- };
629
- }
630
-
631
- let requestPayload: ResponsesCompatibleRequestPayload | undefined;
632
- if (payload !== undefined) {
633
- if (!isResponsesCompatiblePayload(payload)) {
634
- return {
635
- ok: false,
636
- reason: "unsupported-payload",
637
- ...descriptor,
638
- };
639
- }
640
- requestPayload = payload;
641
- }
642
-
643
- let auth: ResolvedRequestAuth;
644
- try {
645
- auth = await resolveRequestAuth(ctx, effectiveModel);
646
- } catch (error) {
647
- return {
648
- ok: false,
649
- reason: "auth-resolution-failed",
650
- errorMessage: error instanceof Error ? error.message : String(error),
651
- ...descriptor,
652
- };
653
- }
654
-
655
- if (!auth.ok) {
656
- return {
657
- ok: false,
658
- reason: "auth-resolution-failed",
659
- errorMessage: auth.error,
660
- ...descriptor,
661
- };
662
- }
663
-
664
- const rawBaseUrl = normalizeBaseUrl(auth.baseUrl) ?? descriptor.baseUrl;
665
- let baseUrl = rawBaseUrl;
666
- if (descriptor.api === "azure-openai-responses") {
667
- baseUrl = buildAzureBaseUrl(rawBaseUrl, auth.env);
668
- }
669
- if (!baseUrl) {
670
- return {
671
- ok: false,
672
- reason: "missing-base-url",
673
- ...descriptor,
674
- };
675
- }
676
- if (descriptor.api === "azure-openai-responses") {
677
- try {
678
- baseUrl = normalizeAzureBaseUrl(baseUrl);
679
- } catch (error) {
680
- return {
681
- ok: false,
682
- reason: "invalid-base-url",
683
- errorMessage: error instanceof Error ? error.message : String(error),
684
- ...descriptor,
685
- baseUrl,
686
- };
687
- }
688
- }
689
-
690
- const requestModel = descriptor.api === "azure-openai-responses"
691
- ? resolveAzureDeploymentName(descriptor.model, auth.env)
692
- : descriptor.model;
693
- if (requestPayload && requestPayload.model !== descriptor.model && requestPayload.model !== requestModel) {
694
- return {
695
- ok: false,
696
- reason: "payload-model-mismatch",
697
- ...descriptor,
698
- baseUrl,
699
- };
700
- }
701
-
702
- const codexAffinity = resolveCodexGatewayAffinity(effectiveModel, options.codexGatewayModels, currentModel);
703
- const sessionId = resolveRuntimeSessionId(ctx);
704
- if (codexAffinity && !sessionId) {
705
- return {
706
- ok: false,
707
- reason: "missing-session-id",
708
- ...descriptor,
709
- baseUrl,
710
- };
711
- }
712
-
713
- if (!auth.apiKey) {
714
- return {
715
- ok: false,
716
- reason: "missing-api-key",
717
- ...descriptor,
718
- baseUrl,
719
- };
720
- }
721
-
722
- return {
723
- ok: true,
724
- runtime: {
725
- provider: descriptor.provider,
726
- api: descriptor.api,
727
- model: descriptor.model,
728
- requestModel,
729
- baseUrl,
730
- apiKey: auth.apiKey,
731
- authMode: resolveAuthMode(ctx, effectiveModel),
732
- env: auth.env,
733
- headers: auth.headers,
734
- responsesPath: buildResponsesPath(descriptor.api),
735
- responsesUrl: buildResponsesUrl(baseUrl, descriptor.api, auth.env),
736
- compactPath: buildCompactPath(descriptor.api),
737
- compactUrl: buildCompactUrl(baseUrl, descriptor.api, auth.env),
738
- sessionId,
739
- codexAffinity,
740
- payload: requestPayload,
741
- currentModel: effectiveModel,
742
- },
743
- };
744
- }
745
-
746
- export async function resolveResponsesEnvironment(
747
- ctx: ExtensionContext,
748
- options: ResponsesSupportOptions = {},
749
- ): Promise<ResponsesEnvironmentResolution> {
750
- const resolution = await resolveNativeCompactionEnvironmentForModel(ctx, ctx.model, options);
751
- if (!resolution.ok) return resolution;
752
-
753
- const runtime = resolution.runtime;
754
- return {
755
- ok: true,
756
- runtime: {
757
- provider: runtime.provider,
758
- api: runtime.api,
759
- model: runtime.model,
760
- requestModel: runtime.requestModel,
761
- baseUrl: runtime.baseUrl,
762
- apiKey: runtime.apiKey,
763
- env: runtime.env,
764
- headers: runtime.headers,
765
- responsesPath: runtime.responsesPath,
766
- responsesUrl: runtime.responsesUrl,
767
- sessionId: runtime.sessionId,
768
- codexAffinity: runtime.codexAffinity,
769
- currentModel: runtime.currentModel,
770
- },
771
- };
772
- }
773
-
774
- export async function resolveNativeCompactionEnvironment(
775
- ctx: ExtensionContext,
776
- options: NativeCompactionSupportOptions = {},
777
- payload?: unknown,
778
- ): Promise<NativeCompactionEnvironmentResolution> {
779
- return resolveNativeCompactionEnvironmentForModel(ctx, ctx.model, options, payload);
780
- }
781
-
782
- export async function resolveRemoteCompactionExecution(
783
- ctx: ExtensionContext,
784
- options: NativeCompactionSupportOptions = {},
785
- remoteModelSpec?: string,
786
- ): Promise<RemoteCompactionExecutionResolution> {
787
- const consumerResolution = await resolveNativeCompactionEnvironment(ctx, options);
788
- if (!consumerResolution.ok) {
789
- return consumerResolution;
790
- }
791
-
792
- const spec = remoteModelSpec?.trim();
793
- if (!spec) {
794
- return {
795
- ok: true,
796
- execution: {
797
- consumer: consumerResolution.runtime,
798
- compactor: consumerResolution.runtime,
799
- },
800
- };
801
- }
802
-
803
- const parsed = parseModelSpec(spec);
804
- if (!parsed) {
805
- return {
806
- ok: false,
807
- reason: "invalid-model-spec",
808
- modelSpec: spec,
809
- };
810
- }
811
-
812
- const compactorModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
813
- if (!compactorModel) {
814
- return {
815
- ok: false,
816
- reason: "model-not-found",
817
- provider: parsed.provider,
818
- model: parsed.modelId,
819
- modelSpec: spec,
820
- };
821
- }
822
-
823
- const compactorResolution = await resolveNativeCompactionEnvironmentForModel(
824
- ctx,
825
- compactorModel,
826
- options,
827
- );
828
- if (!compactorResolution.ok) {
829
- return {
830
- ...compactorResolution,
831
- modelSpec: spec,
832
- };
833
- }
834
-
835
- const consumer = consumerResolution.runtime;
836
- const compactor = compactorResolution.runtime;
837
- if (compactor.baseUrl !== consumer.baseUrl) {
838
- return {
839
- ok: false,
840
- reason: "base-url-mismatch",
841
- provider: compactor.provider,
842
- api: compactor.api,
843
- model: compactor.model,
844
- baseUrl: compactor.baseUrl,
845
- modelSpec: spec,
846
- };
847
- }
848
-
849
- return {
850
- ok: true,
851
- execution: {
852
- consumer,
853
- compactor,
854
- },
855
- };
856
- }
857
-
858
- export async function getNativeCompactionRuntime(
859
- ctx: ExtensionContext,
860
- options: NativeCompactionSupportOptions = {},
861
- payload?: unknown,
862
- ): Promise<NativeCompactionRuntime | undefined> {
863
- const resolution = await resolveNativeCompactionEnvironment(ctx, options, payload);
864
- return resolution.ok ? resolution.runtime : undefined;
865
- }
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Api, Message, Model, ModelThinkingLevel, ProviderHeaders } from "@earendil-works/pi-ai";
3
+ import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { isExactModelAllowed } from "./model-scope";
5
+ import {
6
+ CODEX_AFFINITY_SCOPE,
7
+ buildResponsesRequestHeaders,
8
+ type CodexAffinity,
9
+ } from "./responses-headers";
10
+ import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
11
+
12
+ const OPENAI_RESPONSES_PATH = "responses";
13
+ const CODEX_RESPONSES_PATH = "codex/responses";
14
+ const AZURE_RESPONSES_PATH = "responses";
15
+ const OPENAI_COMPACT_PATH = "responses/compact";
16
+ const CODEX_COMPACT_PATH = "codex/responses/compact";
17
+ const AZURE_COMPACT_PATH = "responses/compact";
18
+ const AZURE_DEFAULT_API_VERSION = "v1";
19
+ const ALPHA_SEARCH_PATH = "alpha/search";
20
+ export const VIRTUAL_MODEL_API = "pi-virtual" as const;
21
+
22
+ export type ResponsesApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
23
+
24
+ type ResponsesCompactApi = ResponsesApi;
25
+ type RuntimeModel = Model<Api>;
26
+
27
+ export type ParsedModelSpec = {
28
+ provider: string;
29
+ modelId: string;
30
+ };
31
+
32
+ export type NativeCompactionFailureReason =
33
+ | "disabled"
34
+ | "missing-model"
35
+ | "virtual-model-unresolved"
36
+ | "invalid-model-spec"
37
+ | "model-not-found"
38
+ | "unsupported-api"
39
+ | "missing-base-url"
40
+ | "invalid-base-url"
41
+ | "missing-session-id"
42
+ | "missing-api-key"
43
+ | "auth-resolution-failed"
44
+ | "unsupported-payload"
45
+ | "payload-model-mismatch"
46
+ | "base-url-mismatch";
47
+
48
+ export type NativeCompactionSupportOptions = {
49
+ enabled?: boolean;
50
+ /** Which Responses APIs should use the compact endpoint; defaults to all capable APIs. */
51
+ responsesApis?: readonly string[];
52
+ /** Exact provider/model keys whose Responses traffic carries gateway Codex affinity metadata. */
53
+ codexGatewayModels?: readonly string[];
54
+ };
55
+
56
+ export type ResponsesSupportOptions = NativeCompactionSupportOptions;
57
+
58
+ export type ResponsesCompatibleRequestPayload = {
59
+ model: string;
60
+ input: unknown[];
61
+ instructions?: unknown;
62
+ [key: string]: unknown;
63
+ };
64
+
65
+ export type ResponsesRuntime = {
66
+ provider: string;
67
+ api: ResponsesApi;
68
+ /** Logical Pi model id. */
69
+ model: string;
70
+ /** Wire model/deployment id; usually the same as `model`. */
71
+ requestModel?: string;
72
+ baseUrl: string;
73
+ apiKey: string;
74
+ env?: Record<string, string>;
75
+ headers?: ProviderHeaders;
76
+ responsesPath: string;
77
+ responsesUrl: string;
78
+ /**
79
+ * Conversation identity for Codex prompt-cache affinity headers. Synthetic
80
+ * compaction/review requests must present the same id the live transcript
81
+ * requests used or the backend treats them as a new conversation.
82
+ */
83
+ sessionId?: string;
84
+ /** Opt-in gateway Codex routing metadata for synthetic Responses/compact requests. */
85
+ codexAffinity?: CodexAffinity;
86
+ currentModel: RuntimeModel;
87
+ };
88
+
89
+ export type NativeCompactionRuntime = ResponsesRuntime & {
90
+ /** Public host snapshot for the resolved physical model; never inferred from token text. */
91
+ authMode?: "oauth" | "non-oauth" | "unknown";
92
+ compactPath: string;
93
+ compactUrl: string;
94
+ payload?: ResponsesCompatibleRequestPayload;
95
+ };
96
+
97
+ export type NativeCompactionEnvironmentFailure = {
98
+ ok: false;
99
+ reason: NativeCompactionFailureReason;
100
+ provider?: string;
101
+ api?: string;
102
+ model?: string;
103
+ baseUrl?: string;
104
+ modelSpec?: string;
105
+ errorMessage?: string;
106
+ };
107
+
108
+ export type NativeCompactionEnvironmentSuccess = {
109
+ ok: true;
110
+ runtime: NativeCompactionRuntime;
111
+ };
112
+
113
+ export type NativeCompactionEnvironmentResolution =
114
+ | NativeCompactionEnvironmentFailure
115
+ | NativeCompactionEnvironmentSuccess;
116
+
117
+ export type ResponsesEnvironmentSuccess = {
118
+ ok: true;
119
+ runtime: ResponsesRuntime;
120
+ };
121
+
122
+ export type ResponsesEnvironmentResolution =
123
+ | NativeCompactionEnvironmentFailure
124
+ | ResponsesEnvironmentSuccess;
125
+
126
+ export type RemoteCompactionExecution = {
127
+ /** Active session model. Its identity owns replay matching and is never mutated. */
128
+ consumer: NativeCompactionRuntime;
129
+ /** Model used only for the synthetic remote_compaction_v2 request. */
130
+ compactor: NativeCompactionRuntime;
131
+ };
132
+
133
+ export type RemoteCompactionExecutionSuccess = {
134
+ ok: true;
135
+ execution: RemoteCompactionExecution;
136
+ };
137
+
138
+ export type RemoteCompactionExecutionResolution =
139
+ | NativeCompactionEnvironmentFailure
140
+ | RemoteCompactionExecutionSuccess;
141
+
142
+ type ResolvedRequestAuth =
143
+ | {
144
+ ok: true;
145
+ apiKey?: string;
146
+ headers?: ProviderHeaders;
147
+ baseUrl?: string;
148
+ env?: Record<string, string>;
149
+ }
150
+ | { ok: false; error: string };
151
+
152
+ type VirtualModelRuntime = {
153
+ resolveModel?: (
154
+ model: RuntimeModel,
155
+ messages: readonly Message[],
156
+ options: {
157
+ reason: "direct";
158
+ thinkingLevel: ModelThinkingLevel;
159
+ signal?: AbortSignal;
160
+ state?: unknown;
161
+ },
162
+ ) => Promise<{ model: RuntimeModel; thinkingLevel: ModelThinkingLevel; state?: unknown }>;
163
+ getPhysicalModel?: (provider: string, modelId: string) => RuntimeModel | undefined;
164
+ };
165
+
166
+ type ModelRegistryRuntimeBridge = {
167
+ runtime?: VirtualModelRuntime;
168
+ };
169
+
170
+ export type EffectiveModelResolution =
171
+ | { ok: true; model: RuntimeModel; virtual: boolean }
172
+ | { ok: false; reason: "missing-model" | "virtual-model-unresolved"; errorMessage?: string };
173
+
174
+ const physicalModelCache = new WeakMap<object, Map<string, RuntimeModel>>();
175
+ const physicalModelObserverApis = new WeakSet<object>();
176
+
177
+ function modelKey(model: RuntimeModel): string {
178
+ return `${model.provider}/${model.id}`;
179
+ }
180
+
181
+ function getPhysicalModelCache(ctx: ExtensionContext): Map<string, RuntimeModel> {
182
+ const registry = ctx.modelRegistry as object;
183
+ let cache = physicalModelCache.get(registry);
184
+ if (!cache) {
185
+ cache = new Map<string, RuntimeModel>();
186
+ physicalModelCache.set(registry, cache);
187
+ }
188
+ return cache;
189
+ }
190
+
191
+ function sessionModelKey(ctx: ExtensionContext, model: RuntimeModel): string | undefined {
192
+ try {
193
+ const sessionId = ctx.sessionManager.getSessionId();
194
+ return sessionId ? `${sessionId}\u0000${modelKey(model)}` : undefined;
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ function isAssistantModelMessage(message: AgentMessage): message is AgentMessage & {
201
+ provider: string;
202
+ api: string;
203
+ model: string;
204
+ } {
205
+ if (message.role !== "assistant") return false;
206
+ const candidate = message as unknown as Record<string, unknown>;
207
+ return typeof candidate.provider === "string" &&
208
+ typeof candidate.api === "string" &&
209
+ typeof candidate.model === "string";
210
+ }
211
+
212
+ /** Remember the physical model Pi actually used as a fallback for a virtual selection. */
213
+ export function rememberPhysicalModel(ctx: ExtensionContext, message: AgentMessage): void {
214
+ const selected = ctx.model as RuntimeModel | undefined;
215
+ if (!selected || selected.api !== VIRTUAL_MODEL_API || !isAssistantModelMessage(message)) return;
216
+ const physical = ctx.modelRegistry.find(message.provider, message.model);
217
+ const key = sessionModelKey(ctx, selected);
218
+ if (physical && physical.api !== VIRTUAL_MODEL_API && key) {
219
+ getPhysicalModelCache(ctx).set(key, physical);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Register the fallback cache observer used by feature extensions. Pi 0.99
225
+ * exposes a resolver for fresh routes; the assistant message cache only helps
226
+ * when a host cannot expose that resolver after a physical response exists.
227
+ */
228
+ export function registerPhysicalModelObserver(pi: ExtensionAPI): void {
229
+ if (physicalModelObserverApis.has(pi)) return;
230
+ physicalModelObserverApis.add(pi);
231
+ pi.on("message_end", (event, ctx) => {
232
+ rememberPhysicalModel(ctx, event.message);
233
+ });
234
+ }
235
+
236
+ async function resolveVirtualModel(ctx: ExtensionContext, model: RuntimeModel): Promise<EffectiveModelResolution> {
237
+ const cache = getPhysicalModelCache(ctx);
238
+ const key = sessionModelKey(ctx, model);
239
+ const bridge = ctx.modelRegistry as unknown as ModelRegistryRuntimeBridge;
240
+ const runtime = bridge.runtime;
241
+ if (typeof runtime?.resolveModel !== "function") {
242
+ const cached = key ? cache.get(key) : undefined;
243
+ if (cached) return { ok: true, model: cached, virtual: true };
244
+ return {
245
+ ok: false,
246
+ reason: "virtual-model-unresolved",
247
+ errorMessage: "Pi does not expose virtual-model routing to extensions.",
248
+ };
249
+ }
250
+
251
+ let messages: Message[];
252
+ try {
253
+ messages = convertToLlm(ctx.sessionManager.buildSessionProjection().messages);
254
+ } catch (error) {
255
+ return {
256
+ ok: false,
257
+ reason: "virtual-model-unresolved",
258
+ errorMessage: error instanceof Error ? error.message : String(error),
259
+ };
260
+ }
261
+
262
+ try {
263
+ const route = await runtime.resolveModel(model, messages, {
264
+ reason: "direct",
265
+ thinkingLevel: (ctx.thinkingLevel ?? "off") as ModelThinkingLevel,
266
+ signal: ctx.signal,
267
+ });
268
+ const physical = runtime.getPhysicalModel?.(route.model.provider, route.model.id)
269
+ ?? ctx.modelRegistry.find(route.model.provider, route.model.id)
270
+ ?? route.model;
271
+ if (!physical || physical.api === VIRTUAL_MODEL_API) {
272
+ return {
273
+ ok: false,
274
+ reason: "virtual-model-unresolved",
275
+ errorMessage: "Pi virtual-model routing did not return a physical model.",
276
+ };
277
+ }
278
+ if (key) cache.set(key, physical);
279
+ return { ok: true, model: physical, virtual: true };
280
+ } catch (error) {
281
+ return {
282
+ ok: false,
283
+ reason: "virtual-model-unresolved",
284
+ errorMessage: error instanceof Error ? error.message : String(error),
285
+ };
286
+ }
287
+ }
288
+
289
+ export async function resolveEffectiveModel(
290
+ ctx: ExtensionContext,
291
+ model: ExtensionContext["model"] = ctx.model,
292
+ ): Promise<EffectiveModelResolution> {
293
+ if (!model) return { ok: false, reason: "missing-model" };
294
+ const runtimeModel = model as RuntimeModel;
295
+ if (runtimeModel.api !== VIRTUAL_MODEL_API) return { ok: true, model: runtimeModel, virtual: false };
296
+ return resolveVirtualModel(ctx, runtimeModel);
297
+ }
298
+
299
+ function normalizeConfiguredApis(values: readonly string[] | undefined): Set<string> {
300
+ if (values === undefined) {
301
+ return new Set(RESPONSES_COMPACT_CAPABLE_APIS);
302
+ }
303
+ return new Set(values.map((value) => value.trim()).filter((value) => value.length > 0));
304
+ }
305
+
306
+ function resolveCodexGatewayAffinity(
307
+ model: RuntimeModel,
308
+ gatewayModels: readonly string[] | undefined,
309
+ logicalModel?: RuntimeModel,
310
+ ): CodexAffinity | undefined {
311
+ const allowlist = gatewayModels ?? [];
312
+ const allowed = isExactModelAllowed(model, allowlist) ||
313
+ (logicalModel?.api === VIRTUAL_MODEL_API && isExactModelAllowed(logicalModel, allowlist));
314
+ if (model.api !== "openai-responses" || !allowed) {
315
+ return undefined;
316
+ }
317
+ return {
318
+ model: model.id,
319
+ scope: CODEX_AFFINITY_SCOPE,
320
+ };
321
+ }
322
+
323
+ /** Parse "provider/model-id" (model ids may themselves contain slashes). */
324
+ export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
325
+ const trimmed = spec.trim();
326
+ const separatorIndex = trimmed.indexOf("/");
327
+ if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
328
+ return undefined;
329
+ }
330
+
331
+ const provider = trimmed.slice(0, separatorIndex).trim();
332
+ const modelId = trimmed.slice(separatorIndex + 1).trim();
333
+ if (!provider || !modelId) {
334
+ return undefined;
335
+ }
336
+
337
+ return { provider, modelId };
338
+ }
339
+
340
+ export function normalizeBaseUrl(baseUrl: string | undefined | null): string | undefined {
341
+ const normalized = baseUrl?.trim().replace(/\/+$/, "");
342
+ return normalized ? normalized : undefined;
343
+ }
344
+
345
+ function buildOpenAIResponsesUrl(baseUrl: string): string {
346
+ const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
347
+ return normalized.endsWith("/responses") ? normalized : `${normalized}/${OPENAI_RESPONSES_PATH}`;
348
+ }
349
+
350
+ function buildCodexResponsesUrl(baseUrl: string): string {
351
+ const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
352
+ if (normalized.endsWith("/codex/responses")) {
353
+ return normalized;
354
+ }
355
+ if (normalized.endsWith("/codex")) {
356
+ return `${normalized}/responses`;
357
+ }
358
+ return `${normalized}/${CODEX_RESPONSES_PATH}`;
359
+ }
360
+
361
+ function buildOpenAICompactUrl(baseUrl: string): string {
362
+ return `${buildOpenAIResponsesUrl(baseUrl)}/compact`;
363
+ }
364
+
365
+ function buildCodexCompactUrl(baseUrl: string): string {
366
+ return `${buildCodexResponsesUrl(baseUrl)}/compact`;
367
+ }
368
+
369
+ function envValue(env: Record<string, string> | undefined, name: string): string | undefined {
370
+ const value = env?.[name]?.trim();
371
+ return value || undefined;
372
+ }
373
+
374
+ function buildAzureBaseUrl(baseUrl: string | undefined, env: Record<string, string> | undefined): string | undefined {
375
+ const configured = envValue(env, "AZURE_OPENAI_BASE_URL") ?? normalizeBaseUrl(baseUrl);
376
+ if (configured) return configured;
377
+ const resource = envValue(env, "AZURE_OPENAI_RESOURCE_NAME");
378
+ return resource ? `https://${resource}.openai.azure.com/openai/v1` : undefined;
379
+ }
380
+
381
+ function normalizeAzureBaseUrl(baseUrl: string): string {
382
+ const url = new URL(baseUrl);
383
+ const pathname = url.pathname.replace(/\/+$/, "");
384
+ if (pathname === "" || pathname === "/" || pathname === "/openai") url.pathname = "/openai/v1";
385
+ else if (pathname.endsWith("/responses")) url.pathname = pathname.slice(0, -"/responses".length);
386
+ url.search = "";
387
+ url.hash = "";
388
+ return url.toString().replace(/\/$/, "");
389
+ }
390
+
391
+ function appendAzureApiVersion(urlValue: string, env: Record<string, string> | undefined): string {
392
+ const url = new URL(urlValue);
393
+ if (!url.searchParams.has("api-version")) {
394
+ url.searchParams.set("api-version", envValue(env, "AZURE_OPENAI_API_VERSION") ?? AZURE_DEFAULT_API_VERSION);
395
+ }
396
+ return url.toString();
397
+ }
398
+
399
+ export function resolveAzureDeploymentName(modelId: string, env: Record<string, string> | undefined): string {
400
+ const mapping = envValue(env, "AZURE_OPENAI_DEPLOYMENT_NAME_MAP");
401
+ if (mapping) {
402
+ for (const entry of mapping.split(",")) {
403
+ const separator = entry.indexOf("=");
404
+ if (separator <= 0) continue;
405
+ if (entry.slice(0, separator).trim() === modelId) {
406
+ const deployment = entry.slice(separator + 1).trim();
407
+ if (deployment) return deployment;
408
+ }
409
+ }
410
+ }
411
+ return modelId;
412
+ }
413
+
414
+ export function buildResponsesUrl(
415
+ baseUrl: string,
416
+ api: ResponsesCompactApi,
417
+ env?: Record<string, string>,
418
+ ): string {
419
+ if (api === "openai-codex-responses") return buildCodexResponsesUrl(baseUrl);
420
+ if (api === "azure-openai-responses") {
421
+ return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_RESPONSES_PATH}`, env);
422
+ }
423
+ return buildOpenAIResponsesUrl(baseUrl);
424
+ }
425
+
426
+ export function buildResponsesPath(api: ResponsesCompactApi): string {
427
+ return api === "openai-codex-responses" ? CODEX_RESPONSES_PATH : AZURE_RESPONSES_PATH;
428
+ }
429
+
430
+ export function buildCompactUrl(
431
+ baseUrl: string,
432
+ api: ResponsesCompactApi,
433
+ env?: Record<string, string>,
434
+ ): string {
435
+ if (api === "openai-codex-responses") return buildCodexCompactUrl(baseUrl);
436
+ if (api === "azure-openai-responses") {
437
+ return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_COMPACT_PATH}`, env);
438
+ }
439
+ return buildOpenAICompactUrl(baseUrl);
440
+ }
441
+
442
+ export function buildCompactPath(api: ResponsesCompactApi): string {
443
+ return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : AZURE_COMPACT_PATH;
444
+ }
445
+
446
+ /**
447
+ * Build the provider-relative standalone-search endpoint. This deliberately
448
+ * rejects a full Responses endpoint so an already-resolved URL cannot become
449
+ * `/responses/alpha/search` or `/codex/responses/alpha/search` by accident.
450
+ */
451
+ export function buildAlphaSearchUrl(baseUrl: string): string | undefined {
452
+ const normalized = normalizeBaseUrl(baseUrl);
453
+ if (!normalized) return undefined;
454
+
455
+ let parsed: URL;
456
+ try {
457
+ parsed = new URL(normalized);
458
+ } catch {
459
+ return undefined;
460
+ }
461
+ if (
462
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
463
+ parsed.username ||
464
+ parsed.password ||
465
+ parsed.search ||
466
+ parsed.hash
467
+ ) {
468
+ return undefined;
469
+ }
470
+
471
+ const pathname = parsed.pathname.replace(/\/+$/, "");
472
+ if (
473
+ pathname.endsWith("/responses/alpha/search") ||
474
+ pathname.endsWith("/codex/responses/alpha/search") ||
475
+ pathname.endsWith("/responses/compact/alpha/search") ||
476
+ pathname.endsWith("/codex/responses/compact/alpha/search")
477
+ ) {
478
+ return undefined;
479
+ }
480
+ if (pathname.endsWith("/alpha/search")) {
481
+ parsed.pathname = pathname;
482
+ return parsed.toString();
483
+ }
484
+ if (
485
+ pathname.endsWith("/responses") ||
486
+ pathname.endsWith("/codex/responses") ||
487
+ pathname.endsWith("/responses/compact") ||
488
+ pathname.endsWith("/codex/responses/compact")
489
+ ) {
490
+ return undefined;
491
+ }
492
+
493
+ parsed.pathname = `${pathname}/${ALPHA_SEARCH_PATH}`.replace(/^\/\//, "/");
494
+ return parsed.toString();
495
+ }
496
+
497
+ async function resolveRequestAuth(ctx: ExtensionContext, model: RuntimeModel): Promise<ResolvedRequestAuth> {
498
+ const modelRegistry = ctx.modelRegistry as {
499
+ getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<ResolvedRequestAuth>;
500
+ };
501
+
502
+ if (typeof modelRegistry.getApiKeyAndHeaders !== "function") {
503
+ return { ok: true };
504
+ }
505
+
506
+ return modelRegistry.getApiKeyAndHeaders(model);
507
+ }
508
+
509
+ function resolveAuthMode(ctx: ExtensionContext, model: RuntimeModel): NativeCompactionRuntime["authMode"] {
510
+ const registry = ctx.modelRegistry as unknown as {
511
+ isUsingOAuth?: (model: RuntimeModel) => unknown;
512
+ hasConfiguredAuth?: (model: RuntimeModel) => unknown;
513
+ };
514
+ try {
515
+ if (typeof registry.isUsingOAuth !== "function" || typeof registry.hasConfiguredAuth !== "function" ||
516
+ registry.hasConfiguredAuth(model) !== true) return "unknown";
517
+ const oauth = registry.isUsingOAuth(model);
518
+ return oauth === true ? "oauth" : oauth === false ? "non-oauth" : "unknown";
519
+ } catch {
520
+ return "unknown";
521
+ }
522
+ }
523
+
524
+ /** Conservative automatic standalone routing; provider aliases alone prove nothing. */
525
+ export function isStandaloneCompactionEligible(runtime: NativeCompactionRuntime): boolean {
526
+ if (runtime.api !== "openai-responses" || runtime.codexAffinity || runtime.authMode !== "non-oauth") return false;
527
+ try {
528
+ const url = new URL(runtime.compactUrl);
529
+ if (url.protocol !== "https:" || url.hostname !== "api.openai.com" || url.port ||
530
+ url.pathname !== "/v1/responses/compact" || url.username || url.password || url.search || url.hash) return false;
531
+ const headers = buildResponsesRequestHeaders(runtime, { accept: "application/json" });
532
+ return headers.get("authorization") === `Bearer ${runtime.apiKey}`;
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
537
+
538
+ export function isSupportedApi(api: string): api is ResponsesCompactApi {
539
+ return (RESPONSES_COMPACT_CAPABLE_APIS as readonly string[]).includes(api);
540
+ }
541
+
542
+ export function isResponsesCompatiblePayload(payload: unknown): payload is ResponsesCompatibleRequestPayload {
543
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
544
+ return false;
545
+ }
546
+
547
+ const candidate = payload as Record<string, unknown>;
548
+ return typeof candidate.model === "string" && Array.isArray(candidate.input);
549
+ }
550
+
551
+ export function getRuntimeModelDescriptor(model: RuntimeModel | undefined): {
552
+ provider?: string;
553
+ api?: string;
554
+ model?: string;
555
+ baseUrl?: string;
556
+ } {
557
+ if (!model) {
558
+ return {};
559
+ }
560
+
561
+ return {
562
+ provider: model.provider,
563
+ api: model.api,
564
+ model: model.id,
565
+ baseUrl: normalizeBaseUrl(model.baseUrl),
566
+ };
567
+ }
568
+
569
+ /** Best-effort conversation id; header emission then simply skips the affinity headers. */
570
+ export function resolveRuntimeSessionId(ctx: ExtensionContext): string | undefined {
571
+ try {
572
+ return ctx.sessionManager.getSessionId();
573
+ } catch {
574
+ return undefined;
575
+ }
576
+ }
577
+
578
+ async function resolveNativeCompactionEnvironmentForModel(
579
+ ctx: ExtensionContext,
580
+ currentModel: RuntimeModel | undefined,
581
+ options: NativeCompactionSupportOptions,
582
+ payload?: unknown,
583
+ ): Promise<NativeCompactionEnvironmentResolution> {
584
+ if (options.enabled === false) {
585
+ return {
586
+ ok: false,
587
+ reason: "disabled",
588
+ };
589
+ }
590
+
591
+ const originalDescriptor = getRuntimeModelDescriptor(currentModel);
592
+ if (!currentModel || !originalDescriptor.provider || !originalDescriptor.api || !originalDescriptor.model) {
593
+ return {
594
+ ok: false,
595
+ reason: "missing-model",
596
+ ...originalDescriptor,
597
+ };
598
+ }
599
+
600
+ const effective = await resolveEffectiveModel(ctx, currentModel);
601
+ if (!effective.ok) {
602
+ return {
603
+ ok: false,
604
+ reason: effective.reason,
605
+ errorMessage: effective.errorMessage,
606
+ ...originalDescriptor,
607
+ };
608
+ }
609
+ const effectiveModel = effective.model;
610
+ const descriptor = getRuntimeModelDescriptor(effectiveModel);
611
+ if (!descriptor.provider || !descriptor.api || !descriptor.model) {
612
+ return {
613
+ ok: false,
614
+ reason: "missing-model",
615
+ ...descriptor,
616
+ };
617
+ }
618
+
619
+ // The compact endpoint is selected purely by API family: any provider speaking
620
+ // a validated Responses API gets a native compact attempt and fails open (to
621
+ // the fallback model or pi's default) when the endpoint is missing.
622
+ const configuredApis = normalizeConfiguredApis(options.responsesApis);
623
+ if (!configuredApis.has(descriptor.api) || !isSupportedApi(descriptor.api)) {
624
+ return {
625
+ ok: false,
626
+ reason: "unsupported-api",
627
+ ...descriptor,
628
+ };
629
+ }
630
+
631
+ let requestPayload: ResponsesCompatibleRequestPayload | undefined;
632
+ if (payload !== undefined) {
633
+ if (!isResponsesCompatiblePayload(payload)) {
634
+ return {
635
+ ok: false,
636
+ reason: "unsupported-payload",
637
+ ...descriptor,
638
+ };
639
+ }
640
+ requestPayload = payload;
641
+ }
642
+
643
+ let auth: ResolvedRequestAuth;
644
+ try {
645
+ auth = await resolveRequestAuth(ctx, effectiveModel);
646
+ } catch (error) {
647
+ return {
648
+ ok: false,
649
+ reason: "auth-resolution-failed",
650
+ errorMessage: error instanceof Error ? error.message : String(error),
651
+ ...descriptor,
652
+ };
653
+ }
654
+
655
+ if (!auth.ok) {
656
+ return {
657
+ ok: false,
658
+ reason: "auth-resolution-failed",
659
+ errorMessage: auth.error,
660
+ ...descriptor,
661
+ };
662
+ }
663
+
664
+ const rawBaseUrl = normalizeBaseUrl(auth.baseUrl) ?? descriptor.baseUrl;
665
+ let baseUrl = rawBaseUrl;
666
+ if (descriptor.api === "azure-openai-responses") {
667
+ baseUrl = buildAzureBaseUrl(rawBaseUrl, auth.env);
668
+ }
669
+ if (!baseUrl) {
670
+ return {
671
+ ok: false,
672
+ reason: "missing-base-url",
673
+ ...descriptor,
674
+ };
675
+ }
676
+ if (descriptor.api === "azure-openai-responses") {
677
+ try {
678
+ baseUrl = normalizeAzureBaseUrl(baseUrl);
679
+ } catch (error) {
680
+ return {
681
+ ok: false,
682
+ reason: "invalid-base-url",
683
+ errorMessage: error instanceof Error ? error.message : String(error),
684
+ ...descriptor,
685
+ baseUrl,
686
+ };
687
+ }
688
+ }
689
+
690
+ const requestModel = descriptor.api === "azure-openai-responses"
691
+ ? resolveAzureDeploymentName(descriptor.model, auth.env)
692
+ : descriptor.model;
693
+ if (requestPayload && requestPayload.model !== descriptor.model && requestPayload.model !== requestModel) {
694
+ return {
695
+ ok: false,
696
+ reason: "payload-model-mismatch",
697
+ ...descriptor,
698
+ baseUrl,
699
+ };
700
+ }
701
+
702
+ const codexAffinity = resolveCodexGatewayAffinity(effectiveModel, options.codexGatewayModels, currentModel);
703
+ const sessionId = resolveRuntimeSessionId(ctx);
704
+ if (codexAffinity && !sessionId) {
705
+ return {
706
+ ok: false,
707
+ reason: "missing-session-id",
708
+ ...descriptor,
709
+ baseUrl,
710
+ };
711
+ }
712
+
713
+ if (!auth.apiKey) {
714
+ return {
715
+ ok: false,
716
+ reason: "missing-api-key",
717
+ ...descriptor,
718
+ baseUrl,
719
+ };
720
+ }
721
+
722
+ return {
723
+ ok: true,
724
+ runtime: {
725
+ provider: descriptor.provider,
726
+ api: descriptor.api,
727
+ model: descriptor.model,
728
+ requestModel,
729
+ baseUrl,
730
+ apiKey: auth.apiKey,
731
+ authMode: resolveAuthMode(ctx, effectiveModel),
732
+ env: auth.env,
733
+ headers: auth.headers,
734
+ responsesPath: buildResponsesPath(descriptor.api),
735
+ responsesUrl: buildResponsesUrl(baseUrl, descriptor.api, auth.env),
736
+ compactPath: buildCompactPath(descriptor.api),
737
+ compactUrl: buildCompactUrl(baseUrl, descriptor.api, auth.env),
738
+ sessionId,
739
+ codexAffinity,
740
+ payload: requestPayload,
741
+ currentModel: effectiveModel,
742
+ },
743
+ };
744
+ }
745
+
746
+ export async function resolveResponsesEnvironment(
747
+ ctx: ExtensionContext,
748
+ options: ResponsesSupportOptions = {},
749
+ ): Promise<ResponsesEnvironmentResolution> {
750
+ const resolution = await resolveNativeCompactionEnvironmentForModel(ctx, ctx.model, options);
751
+ if (!resolution.ok) return resolution;
752
+
753
+ const runtime = resolution.runtime;
754
+ return {
755
+ ok: true,
756
+ runtime: {
757
+ provider: runtime.provider,
758
+ api: runtime.api,
759
+ model: runtime.model,
760
+ requestModel: runtime.requestModel,
761
+ baseUrl: runtime.baseUrl,
762
+ apiKey: runtime.apiKey,
763
+ env: runtime.env,
764
+ headers: runtime.headers,
765
+ responsesPath: runtime.responsesPath,
766
+ responsesUrl: runtime.responsesUrl,
767
+ sessionId: runtime.sessionId,
768
+ codexAffinity: runtime.codexAffinity,
769
+ currentModel: runtime.currentModel,
770
+ },
771
+ };
772
+ }
773
+
774
+ export async function resolveNativeCompactionEnvironment(
775
+ ctx: ExtensionContext,
776
+ options: NativeCompactionSupportOptions = {},
777
+ payload?: unknown,
778
+ ): Promise<NativeCompactionEnvironmentResolution> {
779
+ return resolveNativeCompactionEnvironmentForModel(ctx, ctx.model, options, payload);
780
+ }
781
+
782
+ export async function resolveRemoteCompactionExecution(
783
+ ctx: ExtensionContext,
784
+ options: NativeCompactionSupportOptions = {},
785
+ remoteModelSpec?: string,
786
+ ): Promise<RemoteCompactionExecutionResolution> {
787
+ const consumerResolution = await resolveNativeCompactionEnvironment(ctx, options);
788
+ if (!consumerResolution.ok) {
789
+ return consumerResolution;
790
+ }
791
+
792
+ const spec = remoteModelSpec?.trim();
793
+ if (!spec) {
794
+ return {
795
+ ok: true,
796
+ execution: {
797
+ consumer: consumerResolution.runtime,
798
+ compactor: consumerResolution.runtime,
799
+ },
800
+ };
801
+ }
802
+
803
+ const parsed = parseModelSpec(spec);
804
+ if (!parsed) {
805
+ return {
806
+ ok: false,
807
+ reason: "invalid-model-spec",
808
+ modelSpec: spec,
809
+ };
810
+ }
811
+
812
+ const compactorModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
813
+ if (!compactorModel) {
814
+ return {
815
+ ok: false,
816
+ reason: "model-not-found",
817
+ provider: parsed.provider,
818
+ model: parsed.modelId,
819
+ modelSpec: spec,
820
+ };
821
+ }
822
+
823
+ const compactorResolution = await resolveNativeCompactionEnvironmentForModel(
824
+ ctx,
825
+ compactorModel,
826
+ options,
827
+ );
828
+ if (!compactorResolution.ok) {
829
+ return {
830
+ ...compactorResolution,
831
+ modelSpec: spec,
832
+ };
833
+ }
834
+
835
+ const consumer = consumerResolution.runtime;
836
+ const compactor = compactorResolution.runtime;
837
+ if (compactor.baseUrl !== consumer.baseUrl) {
838
+ return {
839
+ ok: false,
840
+ reason: "base-url-mismatch",
841
+ provider: compactor.provider,
842
+ api: compactor.api,
843
+ model: compactor.model,
844
+ baseUrl: compactor.baseUrl,
845
+ modelSpec: spec,
846
+ };
847
+ }
848
+
849
+ return {
850
+ ok: true,
851
+ execution: {
852
+ consumer,
853
+ compactor,
854
+ },
855
+ };
856
+ }
857
+
858
+ export async function getNativeCompactionRuntime(
859
+ ctx: ExtensionContext,
860
+ options: NativeCompactionSupportOptions = {},
861
+ payload?: unknown,
862
+ ): Promise<NativeCompactionRuntime | undefined> {
863
+ const resolution = await resolveNativeCompactionEnvironment(ctx, options, payload);
864
+ return resolution.ok ? resolution.runtime : undefined;
865
+ }