@oh-my-pi/pi-ai 16.3.4 → 16.3.6

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.
@@ -17,6 +17,14 @@ import type { OAuthController, OAuthCredentials } from "./types";
17
17
  const DEFAULT_TIMEOUT = 300_000;
18
18
  const DEFAULT_HOSTNAME = "localhost";
19
19
  const CALLBACK_PATH = "/callback";
20
+ /**
21
+ * Path served by {@link OAuthCallbackFlow} that 302-redirects to the pending
22
+ * authorization URL. Kept out of {@link OAuthCallbackFlowOptions} because it
23
+ * lives on the loopback callback server alongside {@link CALLBACK_PATH} and
24
+ * must never clash with a provider-registered redirect URI (all known
25
+ * providers register `/callback`-shaped paths).
26
+ */
27
+ const LAUNCH_PATH = "/launch";
20
28
 
21
29
  export type CallbackResult = { code: string; state: string };
22
30
 
@@ -54,6 +62,14 @@ export abstract class OAuthCallbackFlow {
54
62
  allowPortFallback: boolean;
55
63
  #callbackResolve?: (result: CallbackResult) => void;
56
64
  #callbackReject?: (error: string) => void;
65
+ /**
66
+ * Authorization URL the `/launch` route currently redirects to. Set by
67
+ * {@link login} after {@link generateAuthUrl} and before {@link OAuthController.onAuth}
68
+ * fires, cleared when the server stops. `undefined` before the flow reaches
69
+ * that point and after it finishes, so `/launch` returns 503 rather than
70
+ * a stale URL.
71
+ */
72
+ #pendingAuthUrl?: string;
57
73
 
58
74
  constructor(
59
75
  ctrl: OAuthController,
@@ -120,7 +136,7 @@ export abstract class OAuthCallbackFlow {
120
136
  this.#throwIfCancelled();
121
137
 
122
138
  // Start callback server first to get actual redirect URI
123
- const { server, redirectUri } = await this.#startCallbackServer(state);
139
+ const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
124
140
 
125
141
  try {
126
142
  this.#throwIfCancelled();
@@ -128,8 +144,14 @@ export abstract class OAuthCallbackFlow {
128
144
  const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
129
145
  this.#throwIfCancelled();
130
146
 
147
+ // Publish the auth URL to the `/launch` route BEFORE handing it to
148
+ // callers. `onAuth` immediately renders a UI that advertises the
149
+ // launch URL as a copy target, so `/launch` must already resolve if
150
+ // the user clicks/pastes it during the same render pass.
151
+ this.#pendingAuthUrl = authUrl;
152
+
131
153
  // Notify controller that auth is ready
132
- this.ctrl.onAuth?.({ url: authUrl, instructions });
154
+ this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
133
155
  this.ctrl.onProgress?.("Waiting for browser authentication...");
134
156
 
135
157
  // Wait for callback or manual input
@@ -140,21 +162,33 @@ export abstract class OAuthCallbackFlow {
140
162
 
141
163
  return await this.exchangeToken(code, state, redirectUri);
142
164
  } finally {
165
+ this.#pendingAuthUrl = undefined;
143
166
  server.stop();
144
167
  }
145
168
  }
146
169
 
147
170
  /**
148
171
  * Start callback server, trying preferred port first, falling back to random.
172
+ * `launchUrl` is `undefined` when the caller configured `callbackPath` to
173
+ * collide with {@link LAUNCH_PATH} — the callback handler resolves the real
174
+ * callback in that case, so advertising a self-redirecting URL would be
175
+ * incorrect.
149
176
  */
150
- async #startCallbackServer(expectedState: string): Promise<{ server: Bun.Server<unknown>; redirectUri: string }> {
177
+ async #startCallbackServer(
178
+ expectedState: string,
179
+ ): Promise<{ server: Bun.Server<unknown>; redirectUri: string; launchUrl: string | undefined }> {
151
180
  try {
152
181
  const server = this.#createServer(this.preferredPort, expectedState);
182
+ // `preferredPort: 0` opts into a random port — read the actual bound
183
+ // port from the server so both the redirect URI and launch URL point at
184
+ // a reachable socket, not the sentinel.
185
+ const actualPort = this.#resolveServerPort(server);
186
+ const launchUrl = this.#launchUrlIfSafe(actualPort);
153
187
  if (this.redirectUri) {
154
- return { server, redirectUri: this.redirectUri };
188
+ return { server, redirectUri: this.redirectUri, launchUrl };
155
189
  }
156
- const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
157
- return { server, redirectUri };
190
+ const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
191
+ return { server, redirectUri, launchUrl };
158
192
  } catch (cause) {
159
193
  if (this.redirectUri) {
160
194
  throw new AIError.ConfigurationError(
@@ -169,11 +203,49 @@ export abstract class OAuthCallbackFlow {
169
203
  );
170
204
  }
171
205
  const server = this.#createServer(0, expectedState);
172
- const actualPort = server.port;
206
+ const actualPort = this.#resolveServerPort(server);
173
207
  const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
208
+ const launchUrl = this.#launchUrlIfSafe(actualPort);
174
209
  this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
175
- return { server, redirectUri };
210
+ return { server, redirectUri, launchUrl };
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Read the numeric port a callback server bound to. `Bun.Server.port` is
216
+ * declared `number | undefined` because Unix-socket servers have no port,
217
+ * but every callback flow uses TCP; a missing port here indicates a
218
+ * configuration error rather than a fallback case.
219
+ */
220
+ #resolveServerPort(server: Bun.Server<unknown>): number {
221
+ const port = server.port;
222
+ if (typeof port !== "number") {
223
+ throw new AIError.ConfigurationError(
224
+ "OAuth callback server bound to a non-TCP endpoint; expected a numeric port. Check `oauth.callbackPort`/`oauth.redirectUri`.",
225
+ );
226
+ }
227
+ return port;
228
+ }
229
+
230
+ /**
231
+ * Build the `/launch` URL served by the callback server bound to `port`, or
232
+ * `undefined` when the configured `callbackPath` (or a `redirectUri` whose
233
+ * pathname resolves to {@link LAUNCH_PATH}) would collide with the launch
234
+ * route. Kept short (~30 chars) so UIs can advertise it as a
235
+ * viewport-truncation-safe copy target for the full authorization URL.
236
+ */
237
+ #launchUrlIfSafe(port: number): string | undefined {
238
+ if (this.callbackPath === LAUNCH_PATH) return undefined;
239
+ if (this.redirectUri) {
240
+ try {
241
+ if (new URL(this.redirectUri).pathname === LAUNCH_PATH) return undefined;
242
+ } catch {
243
+ // A non-parseable redirectUri (e.g. `vscode://...` handled elsewhere)
244
+ // can't collide with an HTTP `/launch` route — fall through and
245
+ // advertise the launch URL against the loopback server.
246
+ }
176
247
  }
248
+ return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
177
249
  }
178
250
 
179
251
  /**
@@ -190,12 +262,28 @@ export abstract class OAuthCallbackFlow {
190
262
  }
191
263
 
192
264
  /**
193
- * Handle OAuth callback HTTP request.
265
+ * Handle OAuth callback HTTP request. Two routes on the same loopback server:
266
+ * - `callbackPath` (default `/callback`) — the provider redirect target.
267
+ * - {@link LAUNCH_PATH} (`/launch`) — 302 to the pending authorization URL so
268
+ * viewport-safe copy targets can survive TUI truncation.
269
+ *
270
+ * `callbackPath` wins any collision: an OMP config that pins the provider
271
+ * redirect at `/launch` (via `oauth.callbackPath` or a loopback
272
+ * `oauth.redirectUri`) must resolve the callback normally rather than
273
+ * self-redirect. `#startCallbackServer` also suppresses `launchUrl` in that
274
+ * case, so the launch route is never advertised when it would collide.
194
275
  */
195
276
  #handleCallback(req: Request, expectedState: string): Response {
196
277
  const url = new URL(req.url);
197
278
 
198
279
  if (url.pathname !== this.callbackPath) {
280
+ if (url.pathname === LAUNCH_PATH) {
281
+ const pending = this.#pendingAuthUrl;
282
+ if (!pending) {
283
+ return new Response("OAuth launch URL is no longer active", { status: 503 });
284
+ }
285
+ return Response.redirect(pending, 302);
286
+ }
199
287
  return new Response("Not Found", { status: 404 });
200
288
  }
201
289
 
@@ -23,7 +23,21 @@ export type OAuthPrompt = {
23
23
  };
24
24
 
25
25
  export type OAuthAuthInfo = {
26
+ /**
27
+ * Full authorization URL. Suitable for direct browser launch, OSC 8
28
+ * hyperlinks, and clipboard when the target UI can guarantee the full
29
+ * string reaches the user unmodified.
30
+ */
26
31
  url: string;
32
+ /**
33
+ * Short loopback URL that 302-redirects to {@link url}. Provided by flows
34
+ * that host the redirect on the same callback server they already run
35
+ * ({@link OAuthCallbackFlow}). UIs SHOULD prefer this as the copy target
36
+ * so viewport truncation cannot corrupt OAuth query parameters. Undefined
37
+ * for flows without a loopback callback server (device code, paste-code
38
+ * providers with fixed non-loopback redirects, etc.).
39
+ */
40
+ launchUrl?: string;
27
41
  instructions?: string;
28
42
  };
29
43
 
package/src/types.ts CHANGED
@@ -647,6 +647,23 @@ export interface DeveloperMessage {
647
647
  timestamp: number; // Unix timestamp in milliseconds
648
648
  }
649
649
 
650
+ export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
651
+
652
+ export interface AssistantRetryRecovery {
653
+ kind: "auto-retry";
654
+ status: "recovered";
655
+ attempt: number;
656
+ recoveredAt: string;
657
+ recovery: AssistantRetryRecoveryKind;
658
+ note: string;
659
+ supersededBy?: {
660
+ timestamp: number;
661
+ responseId?: string;
662
+ provider: string;
663
+ model: string;
664
+ };
665
+ }
666
+
650
667
  export interface ContextSnapshot {
651
668
  promptTokens: number; // authoritative provider prompt/input tokens
652
669
  nonMessageTokens: number; // estimated non-message total at send time
@@ -660,6 +677,7 @@ export interface AssistantMessage {
660
677
  provider: Provider;
661
678
  model: string;
662
679
  contextSnapshot?: ContextSnapshot;
680
+ retryRecovery?: AssistantRetryRecovery;
663
681
  responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
664
682
  /**
665
683
  * Name of the upstream provider an aggregator routed this request to, as
@@ -615,20 +615,40 @@ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRanki
615
615
  }
616
616
 
617
617
  /**
618
- * Exclude Fable and Mythos tier weekly caps from proactive hard-blocking
619
- * (gating) because they are notoriously unreliable (they report 100% exhausted
620
- * while the account can still serve requests). They stay available for ranking
621
- * pressure in findWindowLimits via scopeClaudeLimitsForModel.
618
+ * A Fable/Mythos weekly row is trusted for gating only at full exhaustion
619
+ * (server `exhausted` status or used fraction >= 1) with a live reset
620
+ * timestamp. Anything below that stays untrusted: the counters are
621
+ * notoriously unreliable short of the cap (they report high utilization
622
+ * while the account can still serve requests).
623
+ */
624
+ function isConfirmedExhaustedTierRow(limit: UsageLimit, nowMs: number): boolean {
625
+ const resetsAt = limit.window?.resetsAt;
626
+ if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return false;
627
+ if (limit.status === "exhausted") return true;
628
+ const fraction = resolveUsedFraction(limit);
629
+ return typeof fraction === "number" && fraction >= 1;
630
+ }
631
+
632
+ /**
633
+ * Scope limits for proactive hard-blocking (gating). Fable and Mythos tier
634
+ * weekly caps participate only when {@link isConfirmedExhaustedTierRow}
635
+ * confirms them, so a confirmed-dead account is skipped up front and a
636
+ * reactive 429 block extends to the tier reset in markUsageLimitReached,
637
+ * while unconfirmed rows remain ranking pressure only via
638
+ * scopeClaudeLimitsForModel.
622
639
  */
623
640
  function scopeClaudeLimitsForModelHardBlock(
624
641
  report: UsageReport,
625
642
  context: CredentialRankingContext | undefined,
626
643
  ): UsageLimit[] {
627
644
  const kind = getClaudeModelKind(context);
628
- const excludeHardBlock = kind === "fable" || kind === "mythos";
629
- return report.limits.filter(
630
- limit => limit.scope.shared === true || (kind !== undefined && limit.scope.tier === kind && !excludeHardBlock),
631
- );
645
+ const requireConfirmedTierRow = kind === "fable" || kind === "mythos";
646
+ const nowMs = Date.now();
647
+ return report.limits.filter(limit => {
648
+ if (limit.scope.shared === true) return true;
649
+ if (kind === undefined || limit.scope.tier !== kind) return false;
650
+ return !requireConfirmedTierRow || isConfirmedExhaustedTierRow(limit, nowMs);
651
+ });
632
652
  }
633
653
 
634
654
  function rankingUsedFraction(limit: UsageLimit): number {
@@ -53,6 +53,7 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
53
53
 
54
54
  6. **Provider payload strict flag must match effective strictness**
55
55
  - Callers MUST send `strict: true` only if enforcement succeeded (`effectiveStrict === true`).
56
+ - Callers MUST preserve an author's explicit `tool.strict === false` on the wire so that `strict: false` and omitted `strict` remain distinguishable — some OpenAI-compat backends over-fill optional fields when the flag is absent but respect it when set to `false` (#4336). Exceptions: `openai-responses` emits explicit `false` only while its `strictMode` gate and `PI_NO_STRICT` permit sending the strict field; `openai-codex-responses` gates explicit `false` on `!PI_NO_STRICT` so the documented global bypass keeps the `strict` key off the wire for Codex proxies that reject it; `openai-completions` emits explicit `false` only in `toolStrictMode === "mixed"` with `compat.supportsStrictMode !== false`, because the `all_strict → none` collapse and providers that reject the `strict` key rely on uniform absence.
56
57
 
57
58
  ---
58
59
 
@@ -1014,6 +1014,137 @@ export function normalizeSchemaForMoonshot(value: unknown): unknown {
1014
1014
  });
1015
1015
  }
1016
1016
 
1017
+ // ---------------------------------------------------------------------------
1018
+ // Ollama — Go schema parser compatibility
1019
+ // ---------------------------------------------------------------------------
1020
+
1021
+ const OLLAMA_SCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
1022
+ const OLLAMA_SCHEMA_MAP_KEYS = new Set([
1023
+ "properties",
1024
+ "patternProperties",
1025
+ "dependencies",
1026
+ "dependentSchemas",
1027
+ "$defs",
1028
+ "definitions",
1029
+ ]);
1030
+ const OLLAMA_SCHEMA_VALUE_KEYS = new Set([
1031
+ "items",
1032
+ "additionalItems",
1033
+ "contains",
1034
+ "contentSchema",
1035
+ "propertyNames",
1036
+ "if",
1037
+ "then",
1038
+ "else",
1039
+ "not",
1040
+ "additionalProperties",
1041
+ "unevaluatedItems",
1042
+ "unevaluatedProperties",
1043
+ ]);
1044
+
1045
+ /**
1046
+ * Widened stand-in for a `true` / `{}` subschema in an Ollama-bound tool.
1047
+ *
1048
+ * `toolWireSchema()` normalizes empty schemas to boolean `true` upstream so
1049
+ * grammar-constrained samplers (llama.cpp, etc.) don't treat `{}` as
1050
+ * "generate an empty object" (issue #1179). Ollama's Go tool parser can't
1051
+ * unmarshal a boolean into its object-shaped `Schema` struct, so this
1052
+ * sanitizer replaces every open subschema with an explicit union of every
1053
+ * primitive JSON type. Both invariants survive: the wire has no boolean
1054
+ * subschema (Go accepts it), and llama.cpp's grammar sees a real value
1055
+ * union rather than a closed empty object.
1056
+ */
1057
+ const OLLAMA_OPEN_SUBSCHEMA_WIDENING = Object.freeze({
1058
+ anyOf: [
1059
+ { type: "string" },
1060
+ { type: "number" },
1061
+ { type: "boolean" },
1062
+ { type: "object" },
1063
+ { type: "array" },
1064
+ { type: "null" },
1065
+ ],
1066
+ });
1067
+
1068
+ /**
1069
+ * Rewrites standard JSON Schema forms that Ollama's Go `/api/chat` tool parser
1070
+ * cannot unmarshal into its object-shaped `Schema` struct.
1071
+ */
1072
+ export function sanitizeSchemaForOllama(schema: JsonObject): JsonObject {
1073
+ const normalizeNode = (value: unknown): unknown => {
1074
+ if (value === true) return OLLAMA_OPEN_SUBSCHEMA_WIDENING;
1075
+ if (value === false) return { not: OLLAMA_OPEN_SUBSCHEMA_WIDENING };
1076
+ if (!isJsonObject(value)) {
1077
+ if (!Array.isArray(value)) return value;
1078
+ let changed = false;
1079
+ const output = value.map(item => {
1080
+ const next = normalizeNode(item);
1081
+ if (next !== item) changed = true;
1082
+ return next;
1083
+ });
1084
+ return changed ? output : value;
1085
+ }
1086
+
1087
+ let changed = false;
1088
+ const output: JsonObject = {};
1089
+ let typeAlternatives: JsonObject[] | undefined;
1090
+ for (const key in value) {
1091
+ if (!Object.hasOwn(value, key)) continue;
1092
+ const child = value[key];
1093
+ if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") {
1094
+ changed = true;
1095
+ continue;
1096
+ }
1097
+ if (key === "type" && Array.isArray(child)) {
1098
+ const variants = child.filter((entry): entry is string => typeof entry === "string");
1099
+ const uniqueVariants = [...new Set(variants)];
1100
+ const nonNull = uniqueVariants.filter(entry => entry !== "null");
1101
+ if (nonNull.length <= 1) {
1102
+ output.type = nonNull[0] ?? uniqueVariants[0] ?? child[0];
1103
+ } else {
1104
+ typeAlternatives = uniqueVariants.map(entry => ({ type: entry }));
1105
+ }
1106
+ changed = true;
1107
+ continue;
1108
+ }
1109
+
1110
+ let next = child;
1111
+ if (OLLAMA_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
1112
+ let mapChanged = false;
1113
+ const mapOutput: JsonObject = {};
1114
+ for (const childKey in child) {
1115
+ if (!Object.hasOwn(child, childKey)) continue;
1116
+ const mapChild = child[childKey];
1117
+ const normalizedChild = normalizeNode(mapChild);
1118
+ if (normalizedChild !== mapChild) mapChanged = true;
1119
+ mapOutput[childKey] = normalizedChild;
1120
+ }
1121
+ next = mapChanged ? mapOutput : child;
1122
+ } else if (OLLAMA_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
1123
+ let arrayChanged = false;
1124
+ const arrayOutput = child.map(item => {
1125
+ const normalizedItem = normalizeNode(item);
1126
+ if (normalizedItem !== item) arrayChanged = true;
1127
+ return normalizedItem;
1128
+ });
1129
+ next = arrayChanged ? arrayOutput : child;
1130
+ } else if (OLLAMA_SCHEMA_VALUE_KEYS.has(key)) {
1131
+ next = normalizeNode(child);
1132
+ }
1133
+ if (next !== child) changed = true;
1134
+ output[key] = next;
1135
+ }
1136
+
1137
+ if (typeAlternatives) {
1138
+ const existingAllOf = output.allOf;
1139
+ const typeUnion = { anyOf: typeAlternatives };
1140
+ output.allOf = Array.isArray(existingAllOf) ? [typeUnion, ...existingAllOf] : [typeUnion];
1141
+ }
1142
+
1143
+ return changed ? output : value;
1144
+ };
1145
+ return normalizeNode(schema) as JsonObject;
1146
+ }
1147
+
1017
1148
  // ---------------------------------------------------------------------------
1018
1149
  // OpenAI Responses — schema-valued normalization
1019
1150
  // ---------------------------------------------------------------------------