@oh-my-pi/pi-ai 16.2.8 → 16.2.9
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/CHANGELOG.md +15 -0
- package/dist/types/error/flags.d.ts +7 -0
- package/dist/types/error/format.d.ts +1 -1
- package/dist/types/registry/oauth/callback-server.d.ts +15 -0
- package/package.json +4 -4
- package/src/error/flags.ts +15 -1
- package/src/error/format.ts +10 -1
- package/src/providers/ollama.ts +6 -0
- package/src/registry/oauth/callback-server.ts +47 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.9] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `OAuthCallbackFlowOptions.allowPortFallback` to allow disabling random-port fallback, enabling strict port enforcement and early configuration errors for OAuth flows with static redirect URIs.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Improved `OAuthCallbackFlow` port conflict error messages to include the busy port, configured redirect URI, and actionable remediation steps.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed an issue where malformed tool-call JSON from local Ollama or llama.cpp models was incorrectly retried as generic 500 errors, now surfacing a clear recovery message.
|
|
18
|
+
- Fixed a race condition in OAuth callback flows where abort signals triggered before the callback listener was registered were ignored.
|
|
19
|
+
|
|
5
20
|
## [16.2.7] - 2026-06-30
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -23,6 +23,13 @@ export declare const Flag: {
|
|
|
23
23
|
export type Flag = (typeof Flag)[keyof typeof Flag];
|
|
24
24
|
export declare const STREAM_READ_ERROR_PATTERN: RegExp;
|
|
25
25
|
export declare const TRANSIENT_TRANSPORT_PATTERN: RegExp;
|
|
26
|
+
/**
|
|
27
|
+
* Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
|
|
28
|
+
* The model emitted invalid JSON in a tool call and the server returned HTTP 500
|
|
29
|
+
* with this exact text — replaying the same prompt yields the same malformed
|
|
30
|
+
* output, so callers strip {@link Flag.Transient} when this matches.
|
|
31
|
+
*/
|
|
32
|
+
export declare const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN: RegExp;
|
|
26
33
|
/** Whether an OAuth refresh error message means the grant is definitively dead. */
|
|
27
34
|
export declare function isOAuthExpiry(errorMessage: string): boolean;
|
|
28
35
|
export declare function create(...flags: number[]): number;
|
|
@@ -5,7 +5,7 @@ export interface FormatMessageOptions {
|
|
|
5
5
|
rawRequestDump?: RawHttpRequestDump;
|
|
6
6
|
/** Captured non-2xx response body, appended to the message when available. */
|
|
7
7
|
capturedErrorResponse?: CapturedHttpErrorResponse;
|
|
8
|
-
/** Provider id;
|
|
8
|
+
/** Provider id; gates provider-specific user-facing rewrites. */
|
|
9
9
|
provider?: string;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
@@ -9,6 +9,20 @@ export interface OAuthCallbackFlowOptions {
|
|
|
9
9
|
callbackHostname?: string;
|
|
10
10
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
11
11
|
redirectUri?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the flow may bind to a random port when {@link preferredPort} is
|
|
14
|
+
* unavailable. Defaults to `true` so historical AI-provider flows (which
|
|
15
|
+
* pick uncommon ports and tolerate any loopback callback) keep working.
|
|
16
|
+
*
|
|
17
|
+
* Set to `false` for providers that validate the redirect URI against a
|
|
18
|
+
* registered callback — silently advertising a random-port URI would be
|
|
19
|
+
* rejected by the authorization server, leaving the browser on an opaque
|
|
20
|
+
* 500 page and the local callback waiting until the 5-minute timeout fires.
|
|
21
|
+
* With fallback disabled, {@link OAuthCallbackFlow.login} throws a
|
|
22
|
+
* {@link AIError.ConfigurationError} immediately so the caller can surface
|
|
23
|
+
* an actionable message before opening the browser.
|
|
24
|
+
*/
|
|
25
|
+
allowPortFallback?: boolean;
|
|
12
26
|
}
|
|
13
27
|
/**
|
|
14
28
|
* Abstract base class for OAuth flows with local callback servers.
|
|
@@ -20,6 +34,7 @@ export declare abstract class OAuthCallbackFlow {
|
|
|
20
34
|
callbackPath: string;
|
|
21
35
|
callbackHostname: string;
|
|
22
36
|
redirectUri?: string;
|
|
37
|
+
allowPortFallback: boolean;
|
|
23
38
|
constructor(ctrl: OAuthController, preferredPortOrOptions: number | OAuthCallbackFlowOptions, callbackPath?: string);
|
|
24
39
|
/**
|
|
25
40
|
* Generate provider-specific authorization URL.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.9",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.2.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.2.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.2.9",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.2.9",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.2.9",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/error/flags.ts
CHANGED
|
@@ -94,6 +94,14 @@ const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
|
94
94
|
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
|
95
95
|
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
|
96
96
|
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
|
|
97
|
+
/**
|
|
98
|
+
* Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
|
|
99
|
+
* The model emitted invalid JSON in a tool call and the server returned HTTP 500
|
|
100
|
+
* with this exact text — replaying the same prompt yields the same malformed
|
|
101
|
+
* output, so callers strip {@link Flag.Transient} when this matches.
|
|
102
|
+
*/
|
|
103
|
+
export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
|
|
104
|
+
/failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
|
|
97
105
|
|
|
98
106
|
// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
|
|
99
107
|
// error, also surfaced in text). Treated as transient — a retry usually lands
|
|
@@ -441,7 +449,13 @@ export function classifyMessage(message: {
|
|
|
441
449
|
const currentStatus = message.errorStatus ?? statusFromId(existingId);
|
|
442
450
|
const textId = classifyText(message.errorMessage, currentStatus, message.api);
|
|
443
451
|
|
|
444
|
-
|
|
452
|
+
let kinds = ((existingId ?? 0) | textId) & KIND_MASK;
|
|
453
|
+
if (message.errorMessage && LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message.errorMessage)) {
|
|
454
|
+
// Deterministic local-model tool-call JSON parse failure: HTTP 500 is misleading
|
|
455
|
+
// because the same prompt reproduces the same malformed output, so the agent-level
|
|
456
|
+
// auto-retry would loop. Strip Transient so the recovery message surfaces immediately.
|
|
457
|
+
kinds &= ~Flag.Transient;
|
|
458
|
+
}
|
|
445
459
|
const id = kinds !== 0 ? create(kinds) : (statusFromId(textId) ?? statusFromId(existingId) ?? currentStatus ?? 0);
|
|
446
460
|
|
|
447
461
|
message.errorId = id;
|
package/src/error/format.ts
CHANGED
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
rewriteCopilotError,
|
|
6
6
|
} from "../utils/http-inspector";
|
|
7
7
|
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
|
8
|
+
import { LLAMA_CPP_TOOL_CALL_PARSE_PATTERN } from "./flags";
|
|
9
|
+
|
|
10
|
+
function rewriteOllamaToolCallJsonError(message: string): string {
|
|
11
|
+
if (!LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(message)) return message;
|
|
12
|
+
return `Local Ollama model emitted malformed tool-call JSON and llama.cpp rejected it (HTTP 500). This is usually a deterministic model-output failure after context degradation, not a transient server outage; reload the model or reduce context, then retry.\n${message}`;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
/** Inputs that steer {@link formatMessage}'s formatter selection. */
|
|
10
16
|
export interface FormatMessageOptions {
|
|
@@ -12,7 +18,7 @@ export interface FormatMessageOptions {
|
|
|
12
18
|
rawRequestDump?: RawHttpRequestDump;
|
|
13
19
|
/** Captured non-2xx response body, appended to the message when available. */
|
|
14
20
|
capturedErrorResponse?: CapturedHttpErrorResponse;
|
|
15
|
-
/** Provider id;
|
|
21
|
+
/** Provider id; gates provider-specific user-facing rewrites. */
|
|
16
22
|
provider?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -32,5 +38,8 @@ export async function formatMessage(error: unknown, opts: FormatMessageOptions =
|
|
|
32
38
|
if (opts.provider === "github-copilot") {
|
|
33
39
|
message = rewriteCopilotError(message, error, opts.provider);
|
|
34
40
|
}
|
|
41
|
+
if (opts.provider === "ollama") {
|
|
42
|
+
message = rewriteOllamaToolCallJsonError(message);
|
|
43
|
+
}
|
|
35
44
|
return message;
|
|
36
45
|
}
|
package/src/providers/ollama.ts
CHANGED
|
@@ -323,6 +323,10 @@ function createChatBody(model: Model<"ollama-chat">, context: Context, options:
|
|
|
323
323
|
};
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
function shouldRetryOllamaResponse(response: Response, bodyText: string): boolean {
|
|
327
|
+
return response.status < 500 || !AIError.LLAMA_CPP_TOOL_CALL_PARSE_PATTERN.test(bodyText);
|
|
328
|
+
}
|
|
329
|
+
|
|
326
330
|
async function captureHttpErrorResponse(response: Response): Promise<CapturedHttpErrorResponse> {
|
|
327
331
|
let bodyText: string | undefined;
|
|
328
332
|
let bodyJson: unknown;
|
|
@@ -598,6 +602,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
598
602
|
body: JSON.stringify(body),
|
|
599
603
|
signal: watchdog.signal,
|
|
600
604
|
defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
|
|
605
|
+
shouldRetryResponse: shouldRetryOllamaResponse,
|
|
601
606
|
fetch: options.fetch,
|
|
602
607
|
timeout: false,
|
|
603
608
|
});
|
|
@@ -747,6 +752,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
747
752
|
}
|
|
748
753
|
const result = await AIError.finalize(error, {
|
|
749
754
|
api: model.api,
|
|
755
|
+
provider: model.provider,
|
|
750
756
|
signal: options.signal,
|
|
751
757
|
rawRequestDump,
|
|
752
758
|
capturedErrorResponse,
|
|
@@ -26,6 +26,20 @@ export interface OAuthCallbackFlowOptions {
|
|
|
26
26
|
callbackHostname?: string;
|
|
27
27
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
28
28
|
redirectUri?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the flow may bind to a random port when {@link preferredPort} is
|
|
31
|
+
* unavailable. Defaults to `true` so historical AI-provider flows (which
|
|
32
|
+
* pick uncommon ports and tolerate any loopback callback) keep working.
|
|
33
|
+
*
|
|
34
|
+
* Set to `false` for providers that validate the redirect URI against a
|
|
35
|
+
* registered callback — silently advertising a random-port URI would be
|
|
36
|
+
* rejected by the authorization server, leaving the browser on an opaque
|
|
37
|
+
* 500 page and the local callback waiting until the 5-minute timeout fires.
|
|
38
|
+
* With fallback disabled, {@link OAuthCallbackFlow.login} throws a
|
|
39
|
+
* {@link AIError.ConfigurationError} immediately so the caller can surface
|
|
40
|
+
* an actionable message before opening the browser.
|
|
41
|
+
*/
|
|
42
|
+
allowPortFallback?: boolean;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
/**
|
|
@@ -37,6 +51,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
37
51
|
callbackPath: string;
|
|
38
52
|
callbackHostname: string;
|
|
39
53
|
redirectUri?: string;
|
|
54
|
+
allowPortFallback: boolean;
|
|
40
55
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
41
56
|
#callbackReject?: (error: string) => void;
|
|
42
57
|
|
|
@@ -50,6 +65,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
50
65
|
this.preferredPort = preferredPortOrOptions;
|
|
51
66
|
this.callbackPath = callbackPath;
|
|
52
67
|
this.callbackHostname = DEFAULT_HOSTNAME;
|
|
68
|
+
this.allowPortFallback = true;
|
|
53
69
|
return;
|
|
54
70
|
}
|
|
55
71
|
|
|
@@ -57,6 +73,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
57
73
|
this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
|
|
58
74
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
59
75
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
76
|
+
this.allowPortFallback = preferredPortOrOptions.allowPortFallback ?? true;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/**
|
|
@@ -87,18 +104,29 @@ export abstract class OAuthCallbackFlow {
|
|
|
87
104
|
.join("");
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
#loginCancelledError(): AIError.LoginCancelledError {
|
|
108
|
+
return new AIError.LoginCancelledError(`OAuth callback cancelled: ${this.ctrl.signal?.reason}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#throwIfCancelled(): void {
|
|
112
|
+
if (this.ctrl.signal?.aborted) throw this.#loginCancelledError();
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* Execute the OAuth login flow.
|
|
92
117
|
*/
|
|
93
118
|
async login(): Promise<OAuthCredentials> {
|
|
94
119
|
const state = this.generateState();
|
|
120
|
+
this.#throwIfCancelled();
|
|
95
121
|
|
|
96
122
|
// Start callback server first to get actual redirect URI
|
|
97
123
|
const { server, redirectUri } = await this.#startCallbackServer(state);
|
|
98
124
|
|
|
99
125
|
try {
|
|
126
|
+
this.#throwIfCancelled();
|
|
100
127
|
// Generate auth URL with the ACTUAL redirect URI (may differ from expected if port was busy)
|
|
101
128
|
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
129
|
+
this.#throwIfCancelled();
|
|
102
130
|
|
|
103
131
|
// Notify controller that auth is ready
|
|
104
132
|
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
@@ -106,6 +134,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
106
134
|
|
|
107
135
|
// Wait for callback or manual input
|
|
108
136
|
const { code } = await this.#waitForCallback(state);
|
|
137
|
+
this.#throwIfCancelled();
|
|
109
138
|
|
|
110
139
|
this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
|
|
111
140
|
|
|
@@ -126,10 +155,17 @@ export abstract class OAuthCallbackFlow {
|
|
|
126
155
|
}
|
|
127
156
|
const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
|
|
128
157
|
return { server, redirectUri };
|
|
129
|
-
} catch {
|
|
158
|
+
} catch (cause) {
|
|
130
159
|
if (this.redirectUri) {
|
|
131
160
|
throw new AIError.ConfigurationError(
|
|
132
|
-
`OAuth callback port ${this.preferredPort}
|
|
161
|
+
`OAuth callback port ${this.preferredPort} is in use, but oauth.redirectUri (${this.redirectUri}) requires this exact port. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or change oauth.redirectUri to point at an available port.`,
|
|
162
|
+
{ cause },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!this.allowPortFallback) {
|
|
166
|
+
throw new AIError.ConfigurationError(
|
|
167
|
+
`OAuth callback port ${this.preferredPort} is in use. The OAuth provider validates redirect URIs against its registered callback, so falling back to a random port would be rejected. Free port ${this.preferredPort} (e.g. stop the process bound to it) and retry, or set oauth.callbackPort/oauth.redirectUri to a port the provider has registered.`,
|
|
168
|
+
{ cause },
|
|
133
169
|
);
|
|
134
170
|
}
|
|
135
171
|
const server = this.#createServer(0, expectedState);
|
|
@@ -208,17 +244,18 @@ export abstract class OAuthCallbackFlow {
|
|
|
208
244
|
#waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
209
245
|
const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
|
|
210
246
|
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
247
|
+
if (signal.aborted) return Promise.reject(this.#loginCancelledError());
|
|
211
248
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
const callback = Promise.withResolvers<CallbackResult>();
|
|
250
|
+
this.#callbackResolve = callback.resolve;
|
|
251
|
+
this.#callbackReject = callback.reject;
|
|
215
252
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
});
|
|
253
|
+
signal.addEventListener("abort", () => {
|
|
254
|
+
this.#callbackResolve = undefined;
|
|
255
|
+
this.#callbackReject = undefined;
|
|
256
|
+
callback.reject(new AIError.LoginCancelledError(`OAuth callback cancelled: ${signal.reason}`));
|
|
221
257
|
});
|
|
258
|
+
const callbackPromise = callback.promise;
|
|
222
259
|
|
|
223
260
|
// Manual input race (if supported)
|
|
224
261
|
if (this.ctrl.onManualCodeInput) {
|