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