@oh-my-pi/pi-ai 17.0.0 → 17.0.2
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 +28 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
- package/dist/types/auth-storage.d.ts +2 -0
- package/dist/types/error/auth-classify.d.ts +2 -0
- package/dist/types/error/rate-limit.d.ts +9 -5
- package/dist/types/providers/cursor.d.ts +2 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
- package/dist/types/providers/openai-shared.d.ts +12 -6
- package/dist/types/providers/transform-messages.d.ts +5 -9
- package/dist/types/types.d.ts +6 -1
- package/dist/types/utils/empty-completion-retry.d.ts +3 -3
- package/dist/types/utils/schema/normalize.d.ts +3 -1
- package/package.json +4 -4
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-retry.ts +2 -2
- package/src/auth-storage.ts +120 -79
- package/src/dialect/owned-stream.ts +11 -0
- package/src/dialect/thinking.ts +186 -16
- package/src/error/auth-classify.ts +13 -0
- package/src/error/flags.ts +2 -2
- package/src/error/rate-limit.ts +28 -9
- package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
- package/src/providers/anthropic.ts +82 -25
- package/src/providers/cursor.ts +53 -30
- package/src/providers/gitlab-duo-workflow.ts +6 -2
- package/src/providers/kimi.ts +1 -4
- package/src/providers/openai-codex/request-transformer.ts +12 -3
- package/src/providers/openai-codex-responses.ts +22 -4
- package/src/providers/openai-completions.ts +28 -22
- package/src/providers/openai-responses.ts +5 -1
- package/src/providers/openai-shared.ts +66 -14
- package/src/providers/transform-messages.ts +171 -1
- package/src/stream.ts +2 -0
- package/src/types.ts +9 -1
- package/src/utils/empty-completion-retry.ts +6 -3
- package/src/utils/leaked-thinking-stream.ts +19 -1
- package/src/utils/proxy.ts +1 -1
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/schema/fields.ts +3 -0
- package/src/utils/schema/normalize.ts +94 -24
- package/src/utils.ts +4 -1
package/src/auth-storage.ts
CHANGED
|
@@ -797,6 +797,8 @@ export interface InvalidateCredentialMatchingOptions {
|
|
|
797
797
|
|
|
798
798
|
/** Options for refreshing one stored OAuth row through durable ownership. */
|
|
799
799
|
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
800
|
+
/** Stable row id when a provider has multiple OAuth credentials. */
|
|
801
|
+
credentialId?: number;
|
|
800
802
|
observedCredential?: T;
|
|
801
803
|
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
802
804
|
forceRefresh?: boolean;
|
|
@@ -2007,17 +2009,32 @@ export class AuthStorage {
|
|
|
2007
2009
|
}
|
|
2008
2010
|
|
|
2009
2011
|
/**
|
|
2010
|
-
* Persist a refreshed credential
|
|
2011
|
-
* A
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
* the row's current index, or -1 when it was disabled
|
|
2012
|
+
* Persist a refreshed credential by id only while the row still matches this
|
|
2013
|
+
* process's snapshot. A peer rotation wins the CAS and is reloaded instead of
|
|
2014
|
+
* being overwritten after this process releases its refresh lease.
|
|
2015
|
+
*
|
|
2016
|
+
* Returns the row's current index, or -1 when it was disabled or removed.
|
|
2015
2017
|
*/
|
|
2016
2018
|
#replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
|
|
2017
2019
|
const entries = this.#getStoredCredentials(provider);
|
|
2018
2020
|
const index = entries.findIndex(entry => entry.id === id);
|
|
2019
2021
|
if (index === -1) return -1;
|
|
2020
|
-
|
|
2022
|
+
const expected = serializeCredential(provider, entries[index]!.credential);
|
|
2023
|
+
if (
|
|
2024
|
+
expected &&
|
|
2025
|
+
this.#store.tryUpdateAuthCredentialIfMatches &&
|
|
2026
|
+
!this.#store.tryUpdateAuthCredentialIfMatches(id, expected.data, credential)
|
|
2027
|
+
) {
|
|
2028
|
+
const latest = this.#store.listAuthCredentials(provider);
|
|
2029
|
+
this.#setStoredCredentials(
|
|
2030
|
+
provider,
|
|
2031
|
+
latest.map(row => ({ id: row.id, credential: row.credential })),
|
|
2032
|
+
);
|
|
2033
|
+
return latest.findIndex(row => row.id === id);
|
|
2034
|
+
}
|
|
2035
|
+
if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
|
|
2036
|
+
this.#store.updateAuthCredential(id, credential);
|
|
2037
|
+
}
|
|
2021
2038
|
const updated = [...entries];
|
|
2022
2039
|
updated[index] = { id, credential };
|
|
2023
2040
|
this.#setStoredCredentials(provider, updated);
|
|
@@ -2150,7 +2167,11 @@ export class AuthStorage {
|
|
|
2150
2167
|
provider,
|
|
2151
2168
|
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
2152
2169
|
);
|
|
2153
|
-
const row = rows.find(
|
|
2170
|
+
const row = rows.find(
|
|
2171
|
+
entry =>
|
|
2172
|
+
entry.credential.type === "oauth" &&
|
|
2173
|
+
(options.credentialId === undefined || entry.id === options.credentialId),
|
|
2174
|
+
);
|
|
2154
2175
|
if (row?.credential.type !== "oauth") {
|
|
2155
2176
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2156
2177
|
}
|
|
@@ -2189,7 +2210,11 @@ export class AuthStorage {
|
|
|
2189
2210
|
provider,
|
|
2190
2211
|
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
2191
2212
|
);
|
|
2192
|
-
const row = rows.find(
|
|
2213
|
+
const row = rows.find(
|
|
2214
|
+
entry =>
|
|
2215
|
+
entry.credential.type === "oauth" &&
|
|
2216
|
+
(options.credentialId === undefined || entry.id === options.credentialId),
|
|
2217
|
+
);
|
|
2193
2218
|
if (row?.credential.type !== "oauth") {
|
|
2194
2219
|
return { credential: undefined, refreshed: false, removed: false };
|
|
2195
2220
|
}
|
|
@@ -2266,7 +2291,7 @@ export class AuthStorage {
|
|
|
2266
2291
|
return { credential: undefined, refreshed: false, removed: true };
|
|
2267
2292
|
}
|
|
2268
2293
|
await this.reload();
|
|
2269
|
-
const latest = this
|
|
2294
|
+
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
|
|
2270
2295
|
return {
|
|
2271
2296
|
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2272
2297
|
refreshed: false,
|
|
@@ -2316,7 +2341,7 @@ export class AuthStorage {
|
|
|
2316
2341
|
)
|
|
2317
2342
|
) {
|
|
2318
2343
|
await this.reload();
|
|
2319
|
-
const latest = this
|
|
2344
|
+
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
|
|
2320
2345
|
return {
|
|
2321
2346
|
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2322
2347
|
refreshed: false,
|
|
@@ -2808,37 +2833,27 @@ export class AuthStorage {
|
|
|
2808
2833
|
return match?.id;
|
|
2809
2834
|
}
|
|
2810
2835
|
|
|
2811
|
-
#persistRefreshedUsageCredential(
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
entry.credential.accountId === previous.accountId &&
|
|
2822
|
-
entry.credential.email === previous.email &&
|
|
2823
|
-
entry.credential.projectId === previous.projectId &&
|
|
2824
|
-
entry.credential.orgId === previous.orgId
|
|
2825
|
-
);
|
|
2826
|
-
});
|
|
2827
|
-
if (index === -1) return;
|
|
2828
|
-
const existing = entries[index]!.credential;
|
|
2829
|
-
if (existing.type !== "oauth") return;
|
|
2830
|
-
this.#replaceCredentialAt(provider, index, {
|
|
2836
|
+
#persistRefreshedUsageCredential(
|
|
2837
|
+
provider: Provider,
|
|
2838
|
+
previous: UsageCredential,
|
|
2839
|
+
next: UsageCredential,
|
|
2840
|
+
credentialId = this.#findStoredCredentialIdForUsageCredential(provider, previous),
|
|
2841
|
+
): void {
|
|
2842
|
+
if (credentialId === undefined) return;
|
|
2843
|
+
const entry = this.#getStoredCredentials(provider).find(candidate => candidate.id === credentialId);
|
|
2844
|
+
if (entry?.credential.type !== "oauth") return;
|
|
2845
|
+
this.#replaceCredentialById(provider, credentialId, {
|
|
2831
2846
|
type: "oauth",
|
|
2832
|
-
access: next.accessToken ??
|
|
2833
|
-
refresh: next.refreshToken ??
|
|
2834
|
-
expires: next.expiresAt ??
|
|
2847
|
+
access: next.accessToken ?? entry.credential.access,
|
|
2848
|
+
refresh: next.refreshToken ?? entry.credential.refresh,
|
|
2849
|
+
expires: next.expiresAt ?? entry.credential.expires,
|
|
2835
2850
|
accountId: next.accountId,
|
|
2836
2851
|
projectId: next.projectId,
|
|
2837
2852
|
email: next.email,
|
|
2838
2853
|
enterpriseUrl: next.enterpriseUrl,
|
|
2839
2854
|
apiEndpoint: next.apiEndpoint,
|
|
2840
|
-
orgId: next.orgId ??
|
|
2841
|
-
orgName: next.orgName ??
|
|
2855
|
+
orgId: next.orgId ?? entry.credential.orgId,
|
|
2856
|
+
orgName: next.orgName ?? entry.credential.orgName,
|
|
2842
2857
|
});
|
|
2843
2858
|
}
|
|
2844
2859
|
|
|
@@ -2878,7 +2893,12 @@ export class AuthStorage {
|
|
|
2878
2893
|
timeoutSignal,
|
|
2879
2894
|
);
|
|
2880
2895
|
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
|
|
2881
|
-
this.#persistRefreshedUsageCredential(
|
|
2896
|
+
this.#persistRefreshedUsageCredential(
|
|
2897
|
+
request.provider,
|
|
2898
|
+
request.credential,
|
|
2899
|
+
refreshedCredential,
|
|
2900
|
+
refreshableCredentialId,
|
|
2901
|
+
);
|
|
2882
2902
|
params = {
|
|
2883
2903
|
...request,
|
|
2884
2904
|
credential: refreshedCredential,
|
|
@@ -2887,46 +2907,16 @@ export class AuthStorage {
|
|
|
2887
2907
|
};
|
|
2888
2908
|
} catch (error) {
|
|
2889
2909
|
const errorMsg = String(error);
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
// freezing in place until the user notices and re-logs in.
|
|
2897
|
-
if (AIError.isDefinitiveOAuthFailure(errorMsg)) {
|
|
2898
|
-
const credentialId = this.#findStoredCredentialIdForUsageCredential(
|
|
2899
|
-
request.provider,
|
|
2900
|
-
request.credential,
|
|
2901
|
-
);
|
|
2902
|
-
if (credentialId !== undefined) {
|
|
2903
|
-
const entries = this.#getStoredCredentials(request.provider);
|
|
2904
|
-
const index = entries.findIndex(entry => entry.id === credentialId);
|
|
2905
|
-
if (index !== -1) {
|
|
2906
|
-
const disabled = this.#tryDisableCredentialAtIfMatches(
|
|
2907
|
-
request.provider,
|
|
2908
|
-
index,
|
|
2909
|
-
refreshableCredential,
|
|
2910
|
-
`oauth refresh failed during usage probe: ${errorMsg}`,
|
|
2911
|
-
);
|
|
2912
|
-
if (disabled) {
|
|
2913
|
-
this.#usageLogger?.warn(
|
|
2914
|
-
"Usage credential refresh failed definitively; credential disabled",
|
|
2915
|
-
{ provider: request.provider, credentialId, error: errorMsg },
|
|
2916
|
-
);
|
|
2917
|
-
// Neutralize last-good for this cache key: write a null
|
|
2918
|
-
// entry with an immediately-elapsed expiry so a future
|
|
2919
|
-
// getStale lookup (e.g. on re-login under the same
|
|
2920
|
-
// account identity) can't replay the stale report.
|
|
2921
|
-
this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
|
|
2922
|
-
value: null,
|
|
2923
|
-
expiresAt: 0,
|
|
2924
|
-
});
|
|
2925
|
-
return null;
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2910
|
+
if (request.credential.expiresAt <= Date.now() && AIError.isDefinitiveOAuthFailure(errorMsg)) {
|
|
2911
|
+
// The current access token is unusable, so don't replay an
|
|
2912
|
+
// old usage report after its rotating refresh token is revoked.
|
|
2913
|
+
// This changes cache state only; usage polling remains
|
|
2914
|
+
// non-authoritative about the credential lifecycle.
|
|
2915
|
+
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
|
|
2929
2916
|
}
|
|
2917
|
+
// Usage polling is advisory. A refresh can fail while the current
|
|
2918
|
+
// access token remains valid inside the refresh skew, so probe with
|
|
2919
|
+
// that token and never mutate credential state from this path.
|
|
2930
2920
|
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
|
|
2931
2921
|
provider: request.provider,
|
|
2932
2922
|
error: errorMsg,
|
|
@@ -3667,6 +3657,7 @@ export class AuthStorage {
|
|
|
3667
3657
|
row.provider as Provider,
|
|
3668
3658
|
initialRequest.credential,
|
|
3669
3659
|
refreshedCredential,
|
|
3660
|
+
row.id,
|
|
3670
3661
|
);
|
|
3671
3662
|
params = {
|
|
3672
3663
|
...params,
|
|
@@ -4317,6 +4308,42 @@ export class AuthStorage {
|
|
|
4317
4308
|
credential: OAuthCredential,
|
|
4318
4309
|
credentialId: number | undefined,
|
|
4319
4310
|
signal?: AbortSignal,
|
|
4311
|
+
): Promise<OAuthCredentials> {
|
|
4312
|
+
const hasDurableLease =
|
|
4313
|
+
!!this.#store.tryAcquireCredentialRefreshLease &&
|
|
4314
|
+
!!this.#store.getCredentialRefreshLeaseExpiresAt &&
|
|
4315
|
+
!!this.#store.releaseCredentialRefreshLease &&
|
|
4316
|
+
!!this.#store.renewCredentialRefreshLease;
|
|
4317
|
+
if (credentialId !== undefined && hasDurableLease) {
|
|
4318
|
+
const forceRefresh = credential.expires === 0;
|
|
4319
|
+
const result = await this.refreshStoredOAuthCredential(provider, {
|
|
4320
|
+
credentialId,
|
|
4321
|
+
observedCredential: forceRefresh ? undefined : credential,
|
|
4322
|
+
credentialFromRow: row => row,
|
|
4323
|
+
forceRefresh,
|
|
4324
|
+
signal,
|
|
4325
|
+
refresh: (current, refreshSignal) =>
|
|
4326
|
+
this.#requestOAuthCredentialRefresh(
|
|
4327
|
+
provider,
|
|
4328
|
+
current,
|
|
4329
|
+
credentialId,
|
|
4330
|
+
signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
|
|
4331
|
+
),
|
|
4332
|
+
});
|
|
4333
|
+
if (result.credential) return result.credential;
|
|
4334
|
+
throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
|
|
4335
|
+
kind: "token-refresh",
|
|
4336
|
+
provider,
|
|
4337
|
+
});
|
|
4338
|
+
}
|
|
4339
|
+
return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
async #requestOAuthCredentialRefresh(
|
|
4343
|
+
provider: Provider,
|
|
4344
|
+
credential: OAuthCredential,
|
|
4345
|
+
credentialId: number | undefined,
|
|
4346
|
+
signal?: AbortSignal,
|
|
4320
4347
|
): Promise<OAuthCredentials> {
|
|
4321
4348
|
let refreshPromise: Promise<OAuthCredentials>;
|
|
4322
4349
|
// Caller override > store-level hook > local per-provider refresh.
|
|
@@ -4343,10 +4370,9 @@ export class AuthStorage {
|
|
|
4343
4370
|
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
|
4344
4371
|
// Caller-driven abort jumps the gun on the timeout — the agent's ESC must
|
|
4345
4372
|
// take priority over the floor timeout.
|
|
4346
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
4347
|
-
let onAbort: (() => void) | undefined;
|
|
4348
4373
|
const cancellation = Promise.withResolvers<never>();
|
|
4349
|
-
|
|
4374
|
+
let onAbort: (() => void) | undefined;
|
|
4375
|
+
const timeout = setTimeout(
|
|
4350
4376
|
() =>
|
|
4351
4377
|
cancellation.reject(
|
|
4352
4378
|
new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
|
|
@@ -4367,7 +4393,7 @@ export class AuthStorage {
|
|
|
4367
4393
|
try {
|
|
4368
4394
|
return await Promise.race([refreshPromise, cancellation.promise]);
|
|
4369
4395
|
} finally {
|
|
4370
|
-
|
|
4396
|
+
clearTimeout(timeout);
|
|
4371
4397
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
4372
4398
|
}
|
|
4373
4399
|
}
|
|
@@ -5359,6 +5385,21 @@ export class AuthStorage {
|
|
|
5359
5385
|
Date.now() + AuthStorage.#defaultBackoffMs,
|
|
5360
5386
|
);
|
|
5361
5387
|
|
|
5388
|
+
if (target && AIError.isInvalidatedOAuthTokenError(error)) {
|
|
5389
|
+
const disabledCause = message ?? "upstream reported invalidated OAuth token";
|
|
5390
|
+
const deleted = this.#store.deleteAuthCredentialRemote
|
|
5391
|
+
? await this.#store.deleteAuthCredentialRemote(target.id, disabledCause)
|
|
5392
|
+
: this.disableCredentialById(target.id, disabledCause);
|
|
5393
|
+
if (deleted) {
|
|
5394
|
+
const latestRows = this.#store.listAuthCredentials(provider);
|
|
5395
|
+
this.#setStoredCredentials(
|
|
5396
|
+
provider,
|
|
5397
|
+
latestRows.map(row => ({ id: row.id, credential: row.credential })),
|
|
5398
|
+
);
|
|
5399
|
+
}
|
|
5400
|
+
return deleted && hasSibling;
|
|
5401
|
+
}
|
|
5402
|
+
|
|
5362
5403
|
if (target) {
|
|
5363
5404
|
const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
|
|
5364
5405
|
if (markSuspect) {
|
|
@@ -109,6 +109,9 @@ export function wrapInbandToolStream(
|
|
|
109
109
|
case "thinking_end":
|
|
110
110
|
projector?.thinkingEnd();
|
|
111
111
|
break;
|
|
112
|
+
case "image_end":
|
|
113
|
+
projector?.keep(event.content);
|
|
114
|
+
break;
|
|
112
115
|
case "text_delta":
|
|
113
116
|
// `text()` returns true once the model starts fabricating its own
|
|
114
117
|
// tool result. In abort mode we cut the turn immediately so the
|
|
@@ -206,6 +209,14 @@ class InbandStreamProjector {
|
|
|
206
209
|
this.#closeText();
|
|
207
210
|
this.#closeThinking();
|
|
208
211
|
this.#partial.content.push(block);
|
|
212
|
+
if (this.#emitEvents && block.type === "image") {
|
|
213
|
+
this.#out.push({
|
|
214
|
+
type: "image_end",
|
|
215
|
+
contentIndex: this.#partial.content.length - 1,
|
|
216
|
+
content: block,
|
|
217
|
+
partial: this.#partial,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
// Forward a native tool call's lifecycle live. `source` comes from the inner
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -32,6 +32,16 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
32
32
|
#thinking = "";
|
|
33
33
|
/** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
|
|
34
34
|
#fenced: FencedThinkingScanner | undefined;
|
|
35
|
+
/** Backtick count that opened the Markdown code span/fence we are inside; 0 when not in code. */
|
|
36
|
+
#codeTicks = 0;
|
|
37
|
+
/** True when {@link #codeTicks} opened a fenced block (closes on a fence line), not an inline span. */
|
|
38
|
+
#codeFenced = false;
|
|
39
|
+
/**
|
|
40
|
+
* Leading-space count on the current output line, or -1 once a non-space
|
|
41
|
+
* character has appeared. Starts at 0 (line start) so a fence opening the
|
|
42
|
+
* stream — or one indented ≤3 spaces, as CommonMark allows — is recognized.
|
|
43
|
+
*/
|
|
44
|
+
#lineIndent = 0;
|
|
35
45
|
|
|
36
46
|
feed(text: string): InbandScanEvent[] {
|
|
37
47
|
if (text.length === 0) return [];
|
|
@@ -86,25 +96,93 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
86
96
|
this.#closeTag = "";
|
|
87
97
|
continue;
|
|
88
98
|
}
|
|
99
|
+
if (this.#codeTicks > 0) {
|
|
100
|
+
if (this.#emitCode(final, events)) continue;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
89
103
|
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
const hit = scanVisible(this.#buffer, final);
|
|
105
|
+
if (hit.kind === "none") {
|
|
106
|
+
this.#emitText(this.#buffer, events);
|
|
107
|
+
this.#buffer = "";
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (hit.index > 0) this.#emitText(this.#buffer.slice(0, hit.index), events);
|
|
111
|
+
if (hit.kind === "hold") {
|
|
112
|
+
this.#buffer = this.#buffer.slice(hit.index);
|
|
96
113
|
break;
|
|
97
114
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
if (hit.kind === "code") {
|
|
116
|
+
const fenced = hit.ticks >= 3 && this.#lineIndent >= 0 && this.#lineIndent <= 3;
|
|
117
|
+
this.#emitText(this.#buffer.slice(hit.index, hit.index + hit.ticks), events);
|
|
118
|
+
this.#buffer = this.#buffer.slice(hit.index + hit.ticks);
|
|
119
|
+
this.#codeTicks = hit.ticks;
|
|
120
|
+
this.#codeFenced = fenced;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
this.#buffer = this.#buffer.slice(hit.index + hit.tag.open.length);
|
|
124
|
+
this.#closeTag = hit.tag.close;
|
|
101
125
|
this.#thinking = "";
|
|
102
|
-
if (tag.fenced) this.#fenced = new FencedThinkingScanner();
|
|
126
|
+
if (hit.tag.fenced) this.#fenced = new FencedThinkingScanner();
|
|
103
127
|
events.push({ type: "thinkingStart" });
|
|
104
128
|
}
|
|
105
129
|
return events;
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Emit buffered content while inside a Markdown code region, suppressing
|
|
134
|
+
* reasoning-tag detection. A fenced block closes only on a fence line (a line
|
|
135
|
+
* of backticks ≥ the opener); an inline span closes on the first backtick run
|
|
136
|
+
* of exactly the opener length. Returns true when the region closed and the
|
|
137
|
+
* loop should continue, false when it held back and should break.
|
|
138
|
+
*/
|
|
139
|
+
#emitCode(final: boolean, events: InbandScanEvent[]): boolean {
|
|
140
|
+
if (this.#codeFenced) {
|
|
141
|
+
const end = findFenceCloseEnd(this.#buffer, this.#codeTicks, final);
|
|
142
|
+
if (end !== -1) {
|
|
143
|
+
this.#emitText(this.#buffer.slice(0, end), events);
|
|
144
|
+
this.#buffer = this.#buffer.slice(end);
|
|
145
|
+
this.#codeTicks = 0;
|
|
146
|
+
this.#codeFenced = false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (final) {
|
|
150
|
+
this.#emitText(this.#buffer, events);
|
|
151
|
+
this.#buffer = "";
|
|
152
|
+
this.#codeTicks = 0;
|
|
153
|
+
this.#codeFenced = false;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
// Stream committed lines; hold only the last (possibly partial) fence line.
|
|
157
|
+
const lastNl = this.#buffer.lastIndexOf("\n");
|
|
158
|
+
if (lastNl !== -1) {
|
|
159
|
+
this.#emitText(this.#buffer.slice(0, lastNl + 1), events);
|
|
160
|
+
this.#buffer = this.#buffer.slice(lastNl + 1);
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const close = findBacktickRun(this.#buffer, 0, this.#codeTicks);
|
|
165
|
+
if (close !== -1 && (final || close + this.#codeTicks < this.#buffer.length)) {
|
|
166
|
+
this.#emitText(this.#buffer.slice(0, close + this.#codeTicks), events);
|
|
167
|
+
this.#buffer = this.#buffer.slice(close + this.#codeTicks);
|
|
168
|
+
this.#codeTicks = 0;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
// No committed close yet: emit text, holding a trailing backtick run that
|
|
172
|
+
// may still grow into — or past — the closing delimiter.
|
|
173
|
+
const hold = final ? 0 : trailingBacktickRun(this.#buffer);
|
|
174
|
+
this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
|
175
|
+
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
176
|
+
if (final) this.#codeTicks = 0;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#emitText(text: string, events: InbandScanEvent[]): void {
|
|
181
|
+
if (text.length === 0) return;
|
|
182
|
+
events.push({ type: "text", text });
|
|
183
|
+
this.#lineIndent = trailingLineIndent(text, this.#lineIndent);
|
|
184
|
+
}
|
|
185
|
+
|
|
108
186
|
#emitThinking(delta: string, events: InbandScanEvent[]): void {
|
|
109
187
|
if (delta.length === 0) return;
|
|
110
188
|
this.#thinking += delta;
|
|
@@ -112,11 +190,103 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
112
190
|
}
|
|
113
191
|
}
|
|
114
192
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
/** Outcome of scanning idle visible text for the next reasoning-tag or code-span boundary. */
|
|
194
|
+
type VisibleHit =
|
|
195
|
+
| { readonly kind: "tag"; readonly index: number; readonly tag: Tag }
|
|
196
|
+
| { readonly kind: "code"; readonly index: number; readonly ticks: number }
|
|
197
|
+
| { readonly kind: "hold"; readonly index: number }
|
|
198
|
+
| { readonly kind: "none" };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Walk idle visible text for the earliest boundary: a leaked reasoning-tag open,
|
|
202
|
+
* a Markdown code-span/fence opener (a backtick run), or — when more chunks may
|
|
203
|
+
* follow — a held partial delimiter at the buffer tail.
|
|
204
|
+
*
|
|
205
|
+
* Reasoning tags win at any position so the gemini ` ```thinking ` fence is
|
|
206
|
+
* healed instead of being read as a code fence. Backtick runs enter code mode so
|
|
207
|
+
* a literal `<think>` inside inline code or a fenced block stays visible text.
|
|
208
|
+
*/
|
|
209
|
+
function scanVisible(buffer: string, final: boolean): VisibleHit {
|
|
210
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
211
|
+
const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
|
|
212
|
+
if (tag) return { kind: "tag", index: i, tag };
|
|
213
|
+
if (!final) {
|
|
214
|
+
const rest = buffer.slice(i);
|
|
215
|
+
if (OPENS.some(open => open.length > rest.length && open.startsWith(rest))) {
|
|
216
|
+
return { kind: "hold", index: i };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (buffer[i] === "`") {
|
|
220
|
+
const ticks = backtickRun(buffer, i);
|
|
221
|
+
if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
|
|
222
|
+
return { kind: "code", index: i, ticks };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { kind: "none" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Length of the maximal backtick run beginning at `from`. */
|
|
229
|
+
function backtickRun(buffer: string, from: number): number {
|
|
230
|
+
let end = from;
|
|
231
|
+
while (end < buffer.length && buffer[end] === "`") end++;
|
|
232
|
+
return end - from;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Index of the first maximal backtick run of exactly `ticks` at/after `from`, else -1. */
|
|
236
|
+
function findBacktickRun(buffer: string, from: number, ticks: number): number {
|
|
237
|
+
for (let i = buffer.indexOf("`", from); i !== -1; i = buffer.indexOf("`", i)) {
|
|
238
|
+
const run = backtickRun(buffer, i);
|
|
239
|
+
if (run === ticks) return i;
|
|
240
|
+
i += run;
|
|
120
241
|
}
|
|
121
|
-
return
|
|
242
|
+
return -1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Length of a backtick run that ends at the buffer tail; 0 when the tail is not a backtick. */
|
|
246
|
+
function trailingBacktickRun(buffer: string): number {
|
|
247
|
+
let start = buffer.length;
|
|
248
|
+
while (start > 0 && buffer[start - 1] === "`") start--;
|
|
249
|
+
return buffer.length - start;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Leading-space count of the line at the tail of `text`, continuing from the
|
|
254
|
+
* prior line's `indent` state (see {@link ThinkingInbandScanner.#lineIndent}).
|
|
255
|
+
* Returns -1 once any non-space character has appeared on the current line.
|
|
256
|
+
*/
|
|
257
|
+
function trailingLineIndent(text: string, prior: number): number {
|
|
258
|
+
const lastNl = text.lastIndexOf("\n");
|
|
259
|
+
let indent = lastNl === -1 ? prior : 0;
|
|
260
|
+
for (let i = lastNl + 1; i < text.length; i++) {
|
|
261
|
+
if (indent === -1) break;
|
|
262
|
+
indent = text[i] === " " ? indent + 1 : -1;
|
|
263
|
+
}
|
|
264
|
+
return indent;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Index just past the first closing fence line for a fenced block opened with
|
|
269
|
+
* `ticks` backticks, or -1 when none is committed yet. A closing fence is a whole
|
|
270
|
+
* line whose trimmed content is only backticks, at least `ticks` of them. A line
|
|
271
|
+
* without a terminating newline is committed only when `final` (no more input can
|
|
272
|
+
* extend it into a non-fence line).
|
|
273
|
+
*/
|
|
274
|
+
function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
|
|
275
|
+
for (let start = 0; start <= buffer.length; ) {
|
|
276
|
+
const nl = buffer.indexOf("\n", start);
|
|
277
|
+
const terminated = nl !== -1;
|
|
278
|
+
const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
|
|
279
|
+
if (line.length >= ticks && isAllBackticks(line) && (terminated || final)) {
|
|
280
|
+
return terminated ? nl + 1 : buffer.length;
|
|
281
|
+
}
|
|
282
|
+
if (!terminated) break;
|
|
283
|
+
start = nl + 1;
|
|
284
|
+
}
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** True when `text` is non-empty and every character is a backtick. */
|
|
289
|
+
function isAllBackticks(text: string): boolean {
|
|
290
|
+
for (let i = 0; i < text.length; i++) if (text[i] !== "`") return false;
|
|
291
|
+
return text.length > 0;
|
|
122
292
|
}
|
|
@@ -12,6 +12,18 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
|
12
12
|
return isOAuthExpiry(errorMsg);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const INVALIDATED_OAUTH_TOKEN_PATTERN = /\binvalidated oauth token\b/i;
|
|
16
|
+
|
|
17
|
+
/** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
|
|
18
|
+
export function isInvalidatedOAuthTokenError(error: unknown): boolean {
|
|
19
|
+
if (typeof error === "object" && error !== null && "errorMessage" in error) {
|
|
20
|
+
const errorMessage = error.errorMessage;
|
|
21
|
+
if (typeof errorMessage === "string" && INVALIDATED_OAUTH_TOKEN_PATTERN.test(errorMessage)) return true;
|
|
22
|
+
}
|
|
23
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
24
|
+
return message !== undefined && INVALIDATED_OAUTH_TOKEN_PATTERN.test(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
28
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
17
29
|
* `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
|
|
@@ -22,6 +34,7 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
|
22
34
|
*/
|
|
23
35
|
export function isAuthRetryableError(error: unknown): boolean {
|
|
24
36
|
if (isUsageLimit(error)) return true;
|
|
37
|
+
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
25
38
|
const httpStatus = extractHttpStatusFromError(error);
|
|
26
39
|
if (httpStatus === 401) return true;
|
|
27
40
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
package/src/error/flags.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
ProviderHttpError,
|
|
8
8
|
STREAM_ENVELOPE_ERROR_PREFIX,
|
|
9
9
|
} from "./classes";
|
|
10
|
-
import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
|
|
10
|
+
import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
|
|
11
11
|
|
|
12
12
|
export const Flag = {
|
|
13
13
|
Class: 0x1000,
|
|
@@ -318,7 +318,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
|
|
318
318
|
const cleanMessage = errorMessage;
|
|
319
319
|
const isOpaque = isOpaqueStatusBody(cleanMessage);
|
|
320
320
|
|
|
321
|
-
const isLimitStatus = statusClean
|
|
321
|
+
const isLimitStatus = isUsageLimitStatus(statusClean);
|
|
322
322
|
if (
|
|
323
323
|
matchesUsageLimitText(cleanMessage) ||
|
|
324
324
|
(isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
|