oira666_pi-limits-wait 0.4.13 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { isInternalSyntheticModel } from "./models.js";
3
3
  import { loadFallbackSettings } from "./settings.js";
4
4
  import { state } from "./state.js";
@@ -29,6 +29,13 @@ export {
29
29
  export { freezingEnabled, waitForRateLimit, waitForRetry } from "./ui.js";
30
30
  export type { FallbackModel } from "./types.js";
31
31
 
32
+ function registerContextApis(pi: ExtensionAPI, ctx: ExtensionContext): void {
33
+ for (const model of ctx.modelRegistry.getAll()) {
34
+ if (!isInternalSyntheticModel(model)) registerWrappedApi(pi, model.api);
35
+ }
36
+ if (ctx.model && !isInternalSyntheticModel(ctx.model)) registerWrappedApi(pi, ctx.model.api);
37
+ }
38
+
32
39
  export default function (pi: ExtensionAPI) {
33
40
  state.extensionApi = pi;
34
41
 
@@ -50,14 +57,20 @@ export default function (pi: ExtensionAPI) {
50
57
 
51
58
  // Some extensions may register providers after this extension loads. Wrap
52
59
  // any APIs that exist by the time an agent starts too.
53
- for (const model of ctx.modelRegistry.getAll()) {
54
- if (!isInternalSyntheticModel(model)) registerWrappedApi(pi, model.api);
55
- }
56
- if (ctx.model && !isInternalSyntheticModel(ctx.model)) registerWrappedApi(pi, ctx.model.api);
60
+ registerContextApis(pi, ctx);
57
61
 
58
62
  // Anthropic subscription/OAuth identity is applied per provider request in
59
63
  // streamWithLimitsRetry(). Doing it there ensures every retry/fallback
60
64
  // attempt gets the right prompt for its actual target model, while
61
65
  // non-Anthropic providers never inherit the Claude Code identity.
62
66
  });
67
+
68
+ // Run once more after all before_agent_start handlers have completed and the
69
+ // agent loop is about to make its first provider request. This catches
70
+ // providers that another extension registered during before_agent_start after
71
+ // our handler had already run.
72
+ pi.on("agent_start", (_event, ctx) => {
73
+ state.sharedCtx = ctx;
74
+ registerContextApis(pi, ctx);
75
+ });
63
76
  }
package/src/models.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
1
2
  import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
2
3
  import { DEFAULT_NON_RETRYABLE_FREEZE_MS } from "./constants.js";
3
4
  import { isRateLimitError, parseRetryDelayMs } from "./retry-errors.js";
@@ -147,13 +148,14 @@ export async function optionsForModel(
147
148
  originalModel: Model<Api>,
148
149
  target: FallbackModel,
149
150
  options?: SimpleStreamOptions,
151
+ ctx: ExtensionContext | undefined = state.sharedCtx,
150
152
  ): Promise<SimpleStreamOptions | undefined> {
151
153
  const level = target.reasoningEffort ?? state.primaryThinkingLevel;
152
154
  const reasoning = level && level !== "off" ? level : undefined;
153
155
  if (modelKey(originalModel) === modelKey(target.model)) {
154
156
  return reasoning ? { ...options, reasoning } : options;
155
157
  }
156
- const auth = await state.sharedCtx?.modelRegistry.getApiKeyAndHeaders(target.model);
158
+ const auth = await ctx?.modelRegistry.getApiKeyAndHeaders(target.model);
157
159
  if (!auth?.ok) throw new Error(auth ? auth.error : "Model registry is unavailable.");
158
160
  return {
159
161
  ...options,
package/src/prompt.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import type { Api, Context, Model } from "@mariozechner/pi-ai";
2
+ import type { Api, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
3
3
  import { CLAUDE_CODE_IDENTITY, CLAUDE_CODE_IDENTITY_PATTERN, PI_IDENTITY_SENTENCE_PATTERN, PI_REMOVAL_ANCHORS } from "./constants.js";
4
4
  import { state } from "./state.js";
5
5
 
@@ -28,8 +28,56 @@ export function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
28
28
  );
29
29
  }
30
30
 
31
- export function anthropicSubscriptionContext(model: Model<Api>, context: Context): Context {
32
- if (model.provider !== "anthropic" || !state.sharedCtx?.modelRegistry.isUsingOAuth(model)) return context;
31
+ export function isAnthropicOAuthToken(value: string | undefined): boolean {
32
+ return Boolean(value?.includes("sk-ant-oat"));
33
+ }
34
+
35
+ function headerValue(headers: Record<string, string> | undefined, name: string): string | undefined {
36
+ if (!headers) return undefined;
37
+ const direct = headers[name];
38
+ if (direct !== undefined) return direct;
39
+ const lower = name.toLowerCase();
40
+ return Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
41
+ }
42
+
43
+ function requestAuthIsAnthropicOAuth(options?: SimpleStreamOptions): boolean | undefined {
44
+ if (typeof options?.apiKey === "string" && options.apiKey.length > 0) {
45
+ return isAnthropicOAuthToken(options.apiKey);
46
+ }
47
+
48
+ const authorization = headerValue(options?.headers, "authorization");
49
+ if (authorization) {
50
+ const bearer = authorization.match(/^\s*Bearer\s+(.+?)\s*$/i)?.[1] ?? authorization;
51
+ return isAnthropicOAuthToken(bearer);
52
+ }
53
+
54
+ return undefined;
55
+ }
56
+
57
+ export function isAnthropicSubscriptionRequest(
58
+ model: Model<Api>,
59
+ options?: SimpleStreamOptions,
60
+ ctx: ExtensionContext | undefined = state.sharedCtx,
61
+ ): boolean {
62
+ if (model.provider !== "anthropic") return false;
63
+
64
+ // Prefer the actual per-request auth. This avoids both false negatives
65
+ // (OAuth token supplied via options/fallback auth while modelRegistry does not
66
+ // report OAuth) and false positives (registry has OAuth, but this request was
67
+ // explicitly made with a normal API key).
68
+ const requestAuth = requestAuthIsAnthropicOAuth(options);
69
+ if (requestAuth !== undefined) return requestAuth;
70
+
71
+ return Boolean(ctx?.modelRegistry.isUsingOAuth(model));
72
+ }
73
+
74
+ export function anthropicSubscriptionContext(
75
+ model: Model<Api>,
76
+ context: Context,
77
+ options?: SimpleStreamOptions,
78
+ ctx: ExtensionContext | undefined = state.sharedCtx,
79
+ ): Context {
80
+ if (!isAnthropicSubscriptionRequest(model, options, ctx)) return context;
33
81
 
34
82
  const sanitised = sanitiseSystemPrompt(context.systemPrompt ?? "");
35
83
  return {
package/src/stream.ts CHANGED
@@ -89,6 +89,7 @@ export function streamWithLimitsRetry(
89
89
  const output = createAssistantMessageEventStream();
90
90
 
91
91
  void (async () => {
92
+ const requestCtx = state.sharedCtx;
92
93
  let committed = false;
93
94
  const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
94
95
  let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: state.primaryThinkingLevel };
@@ -124,14 +125,14 @@ export function streamWithLimitsRetry(
124
125
  try {
125
126
  try {
126
127
  const attemptOptions = allowFallback
127
- ? await optionsForModel(model, attempt, options)
128
+ ? await optionsForModel(model, attempt, options, requestCtx)
128
129
  : options;
129
130
  const attemptDelegate = attempt.model.api === model.api
130
131
  ? delegate
131
132
  : state.builtinStreamSimpleByApi.get(attempt.model.api)
132
133
  ?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
133
134
  if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
134
- const attemptContext = anthropicSubscriptionContext(attempt.model, context);
135
+ const attemptContext = anthropicSubscriptionContext(attempt.model, context, attemptOptions, requestCtx);
135
136
  const inner = await attemptDelegate(attempt.model, attemptContext, attemptOptions);
136
137
  for await (const event of inner) {
137
138
  if (!committed) {
@@ -338,17 +339,34 @@ export function streamWithLimitsRetry(
338
339
 
339
340
  export const streamWithRateLimitRetry = streamWithLimitsRetry;
340
341
 
341
- export function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
342
- if (state.wrappedApis.has(api)) return;
342
+ const LIMITS_WAIT_WRAPPER_FLAG = "__piLimitsWaitWrapper";
343
+ type LimitsWaitStreamSimpleFn = ((
344
+ model: Model<Api>,
345
+ context: Context,
346
+ options?: SimpleStreamOptions,
347
+ ) => AssistantMessageEventStream) & { [LIMITS_WAIT_WRAPPER_FLAG]?: true };
343
348
 
344
- const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
345
- if (!builtinStreamSimple) return;
349
+ export function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
350
+ const currentStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
351
+ if (!currentStreamSimple) return;
352
+ if ((currentStreamSimple as LimitsWaitStreamSimpleFn)[LIMITS_WAIT_WRAPPER_FLAG]) return;
346
353
 
347
- state.builtinStreamSimpleByApi.set(api, builtinStreamSimple);
354
+ state.builtinStreamSimpleByApi.set(api, currentStreamSimple);
348
355
  state.wrappedApis.add(api);
356
+
357
+ const wrappedStreamSimple = ((model: Model<Api>, context: Context, options?: SimpleStreamOptions) =>
358
+ streamWithLimitsRetry(currentStreamSimple, model, context, options)) as LimitsWaitStreamSimpleFn;
359
+ wrappedStreamSimple[LIMITS_WAIT_WRAPPER_FLAG] = true;
360
+
349
361
  pi.registerProvider(`limits-wait-${api}`, {
350
362
  api,
351
- streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) =>
352
- streamWithLimitsRetry(builtinStreamSimple, model, context, options),
363
+ streamSimple: wrappedStreamSimple,
353
364
  });
365
+
366
+ // pi-ai wraps provider functions when registering them, so the function later
367
+ // returned by getApiProviders() is not the same object as wrappedStreamSimple.
368
+ // Tag the registered wrapper too; otherwise repeated agent_start/model loops
369
+ // would wrap an already wrapped API again.
370
+ const registeredStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple as LimitsWaitStreamSimpleFn | undefined;
371
+ if (registeredStreamSimple) registeredStreamSimple[LIMITS_WAIT_WRAPPER_FLAG] = true;
354
372
  }