@oh-my-pi/pi-ai 16.4.4 → 16.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.6] - 2026-07-12
6
+
7
+ ### Added
8
+
9
+ - Added asynchronous `invalidateUsageCache` method to clear cached usage reports
10
+ - Added support for cross-service usage cache invalidation between AuthStorage and AuthBroker
11
+
12
+ ### Fixed
13
+
14
+ - Fixed OAuth credential resolution returning "No API key found" when every plan-eligible OpenAI Codex account was rate-limit blocked and the only unblocked account failed the model's plan gate: resolution now runs a last-resort ladder that first yields a plan-fitting account regardless of usage blocks (so callers get real usage-limit retry semantics), then tries every account with the plan filter dropped before reporting no credential
15
+
16
+ ## [16.4.5] - 2026-07-11
17
+
18
+ ### Fixed
19
+
20
+ - Fixed an issue in GLM tool calling where missing or malformed argument closers (such as `<arg_value>` mistyped as `</arg_key>`) caused subsequent arguments to be swallowed or merged into a single field, affecting both in-band and native tool calling.
21
+
5
22
  ## [16.4.3] - 2026-07-11
6
23
 
7
24
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  import type { AuthCredential } from "../auth-storage.js";
2
- import type { CredentialBlockRequest, CredentialBlockResponse, CredentialBlocksDeleteResponse, CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types.js";
2
+ import type { CredentialBlockRequest, CredentialBlockResponse, CredentialBlocksDeleteResponse, CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse, UsageStaleResponse } from "./types.js";
3
3
  export interface AuthBrokerClientOptions {
4
4
  /** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
5
5
  url: string;
@@ -60,6 +60,7 @@ export declare class AuthBrokerClient {
60
60
  signal?: AbortSignal;
61
61
  }): AsyncGenerator<SnapshotStreamEvent>;
62
62
  fetchUsage(signal?: AbortSignal): Promise<UsageResponse>;
63
+ notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse>;
63
64
  refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
64
65
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
65
66
  uploadCredential(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialUploadResponse>;
@@ -80,6 +80,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
80
80
  getCache(key: string): string | null;
81
81
  setCache(key: string, value: string, expiresAtSec: number): void;
82
82
  cleanExpiredCache(): void;
83
+ invalidateUsageCache(signal?: AbortSignal): Promise<void>;
83
84
  /**
84
85
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
85
86
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -56,6 +56,10 @@ export interface CredentialBlockResponse {
56
56
  export interface CredentialBlocksDeleteResponse {
57
57
  ok: boolean;
58
58
  }
59
+ /** POST /v1/usage/stale response body. */
60
+ export interface UsageStaleResponse {
61
+ ok: boolean;
62
+ }
59
63
  /**
60
64
  * POST /v1/credential request body. The OAuth `refresh` must be the *real*
61
65
  * refresh token (not the sentinel) — the broker is the canonical writer.
@@ -425,6 +425,9 @@ export declare const credentialBlockResponseSchema: import("arktype/internal/var
425
425
  export declare const credentialBlocksDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
426
426
  ok: boolean;
427
427
  }, {}>;
428
+ export declare const usageStaleResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
429
+ ok: boolean;
430
+ }, {}>;
428
431
  export declare const credentialUploadRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
429
432
  provider: string;
430
433
  credential: {
@@ -233,6 +233,8 @@ export interface CredentialRefreshLeaseFence {
233
233
  }
234
234
  export interface AuthCredentialStore {
235
235
  close(): void;
236
+ /** Optional hook to notify the underlying store that usage report cache is stale. */
237
+ invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
236
238
  listAuthCredentials(provider?: string): StoredAuthCredential[];
237
239
  updateAuthCredential(id: number, credential: AuthCredential): void;
238
240
  deleteAuthCredential(id: number, disabledCause: string): void;
@@ -930,6 +932,13 @@ export declare class AuthStorage {
930
932
  baseUrlResolver?: (provider: string) => string | undefined;
931
933
  signal?: AbortSignal;
932
934
  }): Promise<ResetCreditRedeemOutcome>;
935
+ /**
936
+ * Force-invalidate cached usage reports so the next fetch retrieves fresh
937
+ * values from upstream providers. If `provider` is specified, only that
938
+ * provider's credentials are invalidated; otherwise, all credentials in the
939
+ * store are invalidated.
940
+ */
941
+ invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
933
942
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
934
943
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
935
944
  /**
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.4.4",
4
+ "version": "16.4.6",
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.4.4",
42
- "@oh-my-pi/pi-utils": "16.4.4",
43
- "@oh-my-pi/pi-wire": "16.4.4",
41
+ "@oh-my-pi/pi-catalog": "16.4.6",
42
+ "@oh-my-pi/pi-utils": "16.4.6",
43
+ "@oh-my-pi/pi-wire": "16.4.6",
44
44
  "arktype": "2.2.2",
45
45
  "zod": "^4"
46
46
  },
@@ -21,6 +21,7 @@ import type {
21
21
  SnapshotResponse,
22
22
  SnapshotStreamEvent,
23
23
  UsageResponse,
24
+ UsageStaleResponse,
24
25
  } from "./types";
25
26
  import {
26
27
  credentialBlockResponseSchema,
@@ -32,6 +33,7 @@ import {
32
33
  snapshotResponseSchema,
33
34
  snapshotStreamEventSchema,
34
35
  usageResponseSchema,
36
+ usageStaleResponseSchema,
35
37
  } from "./wire-schemas";
36
38
 
37
39
  export interface AuthBrokerClientOptions {
@@ -242,6 +244,13 @@ export class AuthBrokerClient {
242
244
  return this.#request<UsageResponse>("GET", "/v1/usage", { schema: usageResponseSchema, signal });
243
245
  }
244
246
 
247
+ notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse> {
248
+ return this.#request<UsageStaleResponse>("POST", "/v1/usage/stale", {
249
+ schema: usageStaleResponseSchema,
250
+ signal,
251
+ });
252
+ }
253
+
245
254
  async refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse> {
246
255
  return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh`, {
247
256
  schema: credentialRefreshResponseSchema,
@@ -794,6 +794,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
794
794
  }
795
795
  }
796
796
 
797
+ async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
798
+ this.#invalidateUsageCache();
799
+ await this.#client.notifyUsageStale(signal).catch(err => {
800
+ logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
801
+ });
802
+ }
803
+
797
804
  #invalidateUsageCache(): void {
798
805
  this.#usageCache = undefined;
799
806
  this.#usageInflight = undefined;
@@ -608,6 +608,17 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
608
608
  return json(502, { error: message });
609
609
  }
610
610
  }
611
+ if (req.method === "POST" && pathname === "/v1/usage/stale") {
612
+ try {
613
+ opts.storage.invalidateUsageCache?.();
614
+ logger.info("auth-broker usage cache invalidated", { peer });
615
+ return json(200, { ok: true });
616
+ } catch (error) {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ logger.warn("auth-broker usage cache invalidation failed", { peer, error: message });
619
+ return json(500, { error: message });
620
+ }
621
+ }
611
622
  const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
612
623
  if (refreshMatch) {
613
624
  const id = Number.parseInt(refreshMatch[1], 10);
@@ -75,6 +75,11 @@ export interface CredentialBlocksDeleteResponse {
75
75
  ok: boolean;
76
76
  }
77
77
 
78
+ /** POST /v1/usage/stale response body. */
79
+ export interface UsageStaleResponse {
80
+ ok: boolean;
81
+ }
82
+
78
83
  /**
79
84
  * POST /v1/credential request body. The OAuth `refresh` must be the *real*
80
85
  * refresh token (not the sentinel) — the broker is the canonical writer.
@@ -258,6 +258,11 @@ export const credentialBlocksDeleteResponseSchema = type({
258
258
  ok: "boolean",
259
259
  });
260
260
 
261
+ export const usageStaleResponseSchema = type({
262
+ "+": "reject",
263
+ ok: "boolean",
264
+ });
265
+
261
266
  // ─── Upload ────────────────────────────────────────────────────────────────
262
267
 
263
268
  export const credentialUploadRequestSchema = type({
@@ -315,6 +315,8 @@ export interface CredentialRefreshLeaseFence {
315
315
 
316
316
  export interface AuthCredentialStore {
317
317
  close(): void;
318
+ /** Optional hook to notify the underlying store that usage report cache is stale. */
319
+ invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
318
320
  listAuthCredentials(provider?: string): StoredAuthCredential[];
319
321
  updateAuthCredential(id: number, credential: AuthCredential): void;
320
322
  deleteAuthCredential(id: number, disabledCause: string): void;
@@ -3739,8 +3741,17 @@ export class AuthStorage {
3739
3741
 
3740
3742
  /**
3741
3743
  * Resolves an OAuth credential, trying credentials in priority order.
3742
- * Skips blocked credentials and checks usage limits for providers with usage data.
3743
- * Falls back to earliest-unblocking credential if all are blocked.
3744
+ *
3745
+ * Resolution ladder a request in hand always beats "no API key":
3746
+ * 1. strict: unblocked credentials only, usage limits respected, plan
3747
+ * filter enforced (when any account is confirmed eligible);
3748
+ * 2. plan-fitting last resort: same plan filter, but blocked/exhausted
3749
+ * accounts are allowed (blocked candidates rank earliest-unblocking
3750
+ * first) so the caller gets real usage-limit semantics from the wire
3751
+ * instead of a missing key;
3752
+ * 3. unfiltered last resort: the plan filter matched nothing usable —
3753
+ * skip it and try every account once; the server is the final arbiter
3754
+ * of model access.
3744
3755
  *
3745
3756
  * Returns both the API key bytes for outbound requests AND the refreshed
3746
3757
  * {@link OAuthCredential} so callers needing identity metadata (account id,
@@ -3890,42 +3901,34 @@ export class AuthStorage {
3890
3901
  hasPlanRequirement &&
3891
3902
  candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
3892
3903
 
3893
- const fallback = candidates[0];
3904
+ const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [
3905
+ { allowBlocked: false, enforcePlanRequirement },
3906
+ { allowBlocked: true, enforcePlanRequirement },
3907
+ ];
3908
+ if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false });
3894
3909
 
3895
- for (const candidate of candidates) {
3896
- const resolved = await this.#tryOAuthCredential(
3897
- provider,
3898
- candidate.selection,
3899
- providerKey,
3900
- sessionId,
3901
- options,
3902
- {
3903
- checkUsage,
3904
- allowBlocked: false,
3905
- prefetchedUsage: candidate.usage,
3906
- usagePrechecked: candidate.usageChecked,
3907
- planRequirement,
3908
- enforcePlanRequirement,
3909
- strategy,
3910
- rankingContext,
3911
- blockScope,
3912
- },
3913
- );
3914
- if (resolved) return resolved;
3915
- }
3916
-
3917
- if (fallback && this.#isCredentialBlocked(provider, providerKey, fallback.selection.index, blockScope)) {
3918
- return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3919
- checkUsage,
3920
- allowBlocked: true,
3921
- prefetchedUsage: fallback.usage,
3922
- usagePrechecked: fallback.usageChecked,
3923
- planRequirement,
3924
- enforcePlanRequirement,
3925
- strategy,
3926
- rankingContext,
3927
- blockScope,
3928
- });
3910
+ for (const pass of passes) {
3911
+ for (const candidate of candidates) {
3912
+ const resolved = await this.#tryOAuthCredential(
3913
+ provider,
3914
+ candidate.selection,
3915
+ providerKey,
3916
+ sessionId,
3917
+ options,
3918
+ {
3919
+ checkUsage,
3920
+ allowBlocked: pass.allowBlocked,
3921
+ prefetchedUsage: candidate.usage,
3922
+ usagePrechecked: candidate.usageChecked,
3923
+ planRequirement,
3924
+ enforcePlanRequirement: pass.enforcePlanRequirement,
3925
+ strategy,
3926
+ rankingContext,
3927
+ blockScope,
3928
+ },
3929
+ );
3930
+ if (resolved) return resolved;
3931
+ }
3929
3932
  }
3930
3933
 
3931
3934
  return undefined;
@@ -4666,6 +4669,11 @@ export class AuthStorage {
4666
4669
  });
4667
4670
  if (result.ok) {
4668
4671
  this.#invalidateUsageReportCache(provider, baseUrl);
4672
+ if (this.#store.invalidateUsageCache) {
4673
+ await this.#store.invalidateUsageCache(options.signal).catch(err => {
4674
+ logger.debug("Failed to notify store of stale usage", { err });
4675
+ });
4676
+ }
4669
4677
  // The window this credential was blocked on (by markUsageLimitReached)
4670
4678
  // is now reset, so lift its temporary block — otherwise selection
4671
4679
  // keeps skipping/under-ranking the freshly-reset account.
@@ -4691,6 +4699,40 @@ export class AuthStorage {
4691
4699
  }
4692
4700
  }
4693
4701
 
4702
+ /**
4703
+ * Force-invalidate cached usage reports so the next fetch retrieves fresh
4704
+ * values from upstream providers. If `provider` is specified, only that
4705
+ * provider's credentials are invalidated; otherwise, all credentials in the
4706
+ * store are invalidated.
4707
+ */
4708
+ async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
4709
+ if (provider) {
4710
+ this.#invalidateUsageReportCache(provider);
4711
+ } else {
4712
+ this.#usageCacheEpoch += 1;
4713
+ const expired = Date.now() - 1;
4714
+ try {
4715
+ const credentials = this.#store.listAuthCredentials();
4716
+ for (const entry of credentials) {
4717
+ if (entry.credential.type !== "oauth") continue;
4718
+ const cacheKey = this.#buildUsageReportCacheKey(
4719
+ this.#buildUsageRequestForOauth(entry.provider, entry.credential),
4720
+ );
4721
+ const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
4722
+ this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired });
4723
+ }
4724
+ } catch (err) {
4725
+ logger.debug("Failed to list auth credentials for complete usage cache invalidation", { err });
4726
+ }
4727
+ }
4728
+
4729
+ if (this.#store.invalidateUsageCache) {
4730
+ await this.#store.invalidateUsageCache(signal).catch(err => {
4731
+ logger.debug("Failed to notify store of stale usage", { err });
4732
+ });
4733
+ }
4734
+ }
4735
+
4694
4736
  #invalidateUsageReportCacheForProviderKey(providerKey: string): void {
4695
4737
  const oauthSuffix = ":oauth";
4696
4738
  if (!providerKey.endsWith(oauthSuffix)) return;
@@ -272,8 +272,19 @@ export class GLMInbandScanner implements InbandScanner {
272
272
 
273
273
  #consumeValue(final: boolean, events: InbandScanEvent[]): boolean {
274
274
  const close = this.#buffer.indexOf(ARG_VALUE_CLOSE);
275
+ const heal = scanValueHeal(this.#buffer, close === -1 ? this.#buffer.length : close);
276
+ if (heal.kind === "heal") {
277
+ this.#streamValue(this.#buffer.slice(0, heal.valueEnd), events);
278
+ if (heal.trimValue && this.#call) this.#call.valueRaw = this.#call.valueRaw.trimEnd();
279
+ this.#appendCallRaw(this.#buffer.slice(heal.valueEnd, heal.resumeAt));
280
+ this.#buffer = this.#buffer.slice(heal.resumeAt);
281
+ this.#endValue();
282
+ this.#state = "body";
283
+ return true;
284
+ }
275
285
  if (close === -1) {
276
- const hold = final ? 0 : partialSuffixOverlap(this.#buffer, ARG_VALUE_CLOSE);
286
+ const healHold = heal.kind === "partial" ? this.#buffer.length - heal.start : 0;
287
+ const hold = final ? 0 : Math.max(partialSuffixOverlap(this.#buffer, ARG_VALUE_CLOSE), healHold);
277
288
  const emit = this.#buffer.slice(0, this.#buffer.length - hold);
278
289
  this.#streamValue(emit, events);
279
290
  this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
@@ -389,6 +400,118 @@ function minFound(...values: readonly number[]): number {
389
400
  return best;
390
401
  }
391
402
 
403
+ /** Max whitespace tolerated between heal-signature tags before giving up. */
404
+ const HEAL_WS_MAX = 32;
405
+ /** Max key length considered plausible for an inlined `<arg_key>…</arg_key>` pair. */
406
+ const HEAL_KEY_MAX = 128;
407
+
408
+ /**
409
+ * Result of scanning a streaming `<arg_value>` body for a forgotten or
410
+ * mistyped `</arg_value>` closer.
411
+ *
412
+ * - `heal`: a repair signature starts inside the value; the value ends at
413
+ * `valueEnd` and parsing resumes at `resumeAt` in "body" state.
414
+ * `trimValue` marks boundaries inferred from separator formatting, whose
415
+ * trailing whitespace belongs to the syntax, not the value.
416
+ * - `partial`: a signature may be forming at `start` but the buffer ends
417
+ * before it can be confirmed; the caller must hold `start..` back from
418
+ * streaming.
419
+ */
420
+ type ValueHealScan =
421
+ | { kind: "none" }
422
+ | { kind: "partial"; start: number }
423
+ | { kind: "heal"; valueEnd: number; resumeAt: number; trimValue: boolean };
424
+
425
+ type HealFollow = { kind: "match"; resumeAt: number } | { kind: "partial" } | { kind: "none" };
426
+
427
+ type TagPrefixMatch = "match" | "partial" | "none";
428
+
429
+ /**
430
+ * Finds the earliest heal signature starting before `limit` (the legit
431
+ * `</arg_value>` close, or end of buffer when absent). Two signatures repair
432
+ * a value whose closer the model botched:
433
+ *
434
+ * - Wrong closer: `</arg_key>` followed by `<arg_key>`, `</tool_call>`, or
435
+ * `</arg_value>` — the model closed the value with the wrong tag.
436
+ * - Missing closer: a complete `<arg_key>…</arg_key>` + `<arg_value>`
437
+ * sequence — the model started the next pair without closing the value.
438
+ *
439
+ * Without repair, either mistake swallows every following pair into the
440
+ * current value until the next `</arg_value>` anywhere in the stream.
441
+ */
442
+ function scanValueHeal(text: string, limit: number): ValueHealScan {
443
+ for (let at = text.indexOf("<"); at !== -1 && at < limit; at = text.indexOf("<", at + 1)) {
444
+ const scan = matchHealSignature(text, at);
445
+ if (scan.kind !== "none") return scan;
446
+ }
447
+ return { kind: "none" };
448
+ }
449
+
450
+ function matchHealSignature(text: string, start: number): ValueHealScan {
451
+ const wrongCloser = matchTagPrefix(text, start, ARG_KEY_CLOSE);
452
+ if (wrongCloser === "partial") return { kind: "partial", start };
453
+ if (wrongCloser === "match") {
454
+ const follow = matchHealFollow(text, start + ARG_KEY_CLOSE.length);
455
+ if (follow.kind === "partial") return { kind: "partial", start };
456
+ if (follow.kind === "match")
457
+ return { kind: "heal", valueEnd: start, resumeAt: follow.resumeAt, trimValue: false };
458
+ return { kind: "none" };
459
+ }
460
+
461
+ const nextKey = matchTagPrefix(text, start, ARG_KEY_OPEN);
462
+ if (nextKey === "partial") return { kind: "partial", start };
463
+ if (nextKey === "none") return { kind: "none" };
464
+ let at = start + ARG_KEY_OPEN.length;
465
+ const keyEnd = Math.min(text.length, at + HEAL_KEY_MAX);
466
+ while (at < keyEnd && text[at] !== "<" && text[at] !== "\n") at++;
467
+ if (at === text.length) return { kind: "partial", start };
468
+ if (text[at] !== "<") return { kind: "none" };
469
+ const keyClose = matchTagPrefix(text, at, ARG_KEY_CLOSE);
470
+ if (keyClose === "partial") return { kind: "partial", start };
471
+ if (keyClose === "none") return { kind: "none" };
472
+ at = skipHealWhitespace(text, at + ARG_KEY_CLOSE.length);
473
+ if (at === -1) return { kind: "none" };
474
+ if (at === text.length) return { kind: "partial", start };
475
+ const value = matchTagPrefix(text, at, ARG_VALUE_OPEN);
476
+ if (value === "partial") return { kind: "partial", start };
477
+ if (value === "none") return { kind: "none" };
478
+ return { kind: "heal", valueEnd: start, resumeAt: start, trimValue: true };
479
+ }
480
+
481
+ /** Matches the tag expected after a wrong `</arg_key>` closer. */
482
+ function matchHealFollow(text: string, from: number): HealFollow {
483
+ const at = skipHealWhitespace(text, from);
484
+ if (at === -1) return { kind: "none" };
485
+ if (at === text.length) return { kind: "partial" };
486
+ for (const tag of [ARG_KEY_OPEN, TOOL_CLOSE]) {
487
+ const match = matchTagPrefix(text, at, tag);
488
+ if (match === "match") return { kind: "match", resumeAt: at };
489
+ if (match === "partial") return { kind: "partial" };
490
+ }
491
+ const close = matchTagPrefix(text, at, ARG_VALUE_CLOSE);
492
+ if (close === "match") return { kind: "match", resumeAt: at + ARG_VALUE_CLOSE.length };
493
+ if (close === "partial") return { kind: "partial" };
494
+ return { kind: "none" };
495
+ }
496
+
497
+ /** Skips whitespace from `from`; -1 when the run exceeds {@link HEAL_WS_MAX}. */
498
+ function skipHealWhitespace(text: string, from: number): number {
499
+ let at = from;
500
+ while (at < text.length && " \n\t\r".includes(text[at]!)) {
501
+ at++;
502
+ if (at - from > HEAL_WS_MAX) return -1;
503
+ }
504
+ return at;
505
+ }
506
+
507
+ function matchTagPrefix(text: string, at: number, tag: string): TagPrefixMatch {
508
+ const available = Math.min(text.length - at, tag.length);
509
+ for (let k = 0; k < available; k++) {
510
+ if (text.charCodeAt(at + k) !== tag.charCodeAt(k)) return "none";
511
+ }
512
+ return available === tag.length ? "match" : "partial";
513
+ }
514
+
392
515
  function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
393
516
  return glmInvocation(call, buildArgShapes(options.tools).get(call.name));
394
517
  }
@@ -1659,6 +1659,161 @@ function validateContext(ctx: ValidationContext, value: unknown): ContextValidat
1659
1659
  };
1660
1660
  }
1661
1661
 
1662
+ // In-band `arg_key`/`arg_value` tool-call syntax that leaks into native
1663
+ // tool-call arguments when a provider parses the model's owned format
1664
+ // server-side and the model botches an `</arg_value>` closer.
1665
+ const SPILL_KEY_OPEN = "<arg_key>";
1666
+ const SPILL_KEY_CLOSE = "</arg_key>";
1667
+ const SPILL_VALUE_OPEN = "<arg_value>";
1668
+ const SPILL_VALUE_CLOSE = "</arg_value>";
1669
+ const SPILL_TOOL_CLOSE = "</tool_call>";
1670
+ /** Plausible spilled argument names; anything else is ordinary content. */
1671
+ const SPILL_KEY_PATTERN = /^[\w.$-]{1,128}$/;
1672
+
1673
+ interface SpillSplit {
1674
+ head: string;
1675
+ pairs: [string, string][];
1676
+ }
1677
+
1678
+ function skipSpillWhitespace(text: string, from: number): number {
1679
+ let at = from;
1680
+ while (at < text.length && " \n\t\r".includes(text[at]!)) at++;
1681
+ return at;
1682
+ }
1683
+
1684
+ /** Whether a well-formed `<arg_key>NAME</arg_key>…<arg_value>` pair starts at `at`. */
1685
+ function isSpillPairStart(text: string, at: number): boolean {
1686
+ if (!text.startsWith(SPILL_KEY_OPEN, at)) return false;
1687
+ const keyStart = at + SPILL_KEY_OPEN.length;
1688
+ const keyEnd = text.indexOf(SPILL_KEY_CLOSE, keyStart);
1689
+ if (keyEnd === -1 || !SPILL_KEY_PATTERN.test(text.slice(keyStart, keyEnd))) return false;
1690
+ const valueAt = skipSpillWhitespace(text, keyEnd + SPILL_KEY_CLOSE.length);
1691
+ return text.startsWith(SPILL_VALUE_OPEN, valueAt);
1692
+ }
1693
+
1694
+ /**
1695
+ * Finds where a spilled `<arg_value>` body ends: the legit closer, a
1696
+ * mistyped `</arg_key>` closer (validated by its follow-up), the start of the
1697
+ * next pair when the closer is missing entirely, or end of input (the
1698
+ * provider's parser consumed the terminating closer).
1699
+ */
1700
+ function findSpillValueEnd(text: string, from: number): { end: number; next: number } {
1701
+ const close = text.indexOf(SPILL_VALUE_CLOSE, from);
1702
+ let wrong = text.indexOf(SPILL_KEY_CLOSE, from);
1703
+ let open = text.indexOf(SPILL_KEY_OPEN, from);
1704
+ while (true) {
1705
+ const candidates = [close, wrong, open].filter(index => index !== -1);
1706
+ if (candidates.length === 0) return { end: text.length, next: text.length };
1707
+ const at = Math.min(...candidates);
1708
+ if (at === close) return { end: at, next: at + SPILL_VALUE_CLOSE.length };
1709
+ if (at === wrong) {
1710
+ const follow = skipSpillWhitespace(text, at + SPILL_KEY_CLOSE.length);
1711
+ if (
1712
+ follow >= text.length ||
1713
+ text.startsWith(SPILL_KEY_OPEN, follow) ||
1714
+ text.startsWith(SPILL_TOOL_CLOSE, follow)
1715
+ ) {
1716
+ return { end: at, next: at + SPILL_KEY_CLOSE.length };
1717
+ }
1718
+ wrong = text.indexOf(SPILL_KEY_CLOSE, at + 1);
1719
+ continue;
1720
+ }
1721
+ if (isSpillPairStart(text, at)) {
1722
+ let end = at;
1723
+ while (end > from && " \n\t\r".includes(text[end - 1]!)) end--;
1724
+ return { end, next: at };
1725
+ }
1726
+ open = text.indexOf(SPILL_KEY_OPEN, at + 1);
1727
+ }
1728
+ }
1729
+
1730
+ /**
1731
+ * Strictly parses a spill tail as `<arg_key>…</arg_key><arg_value>…` pairs,
1732
+ * tolerating a trailing `</tool_call>`. Returns null on any shape that is not
1733
+ * pure pair syntax — the caller then treats the text as ordinary content.
1734
+ */
1735
+ function parseSpilledPairs(text: string): [string, string][] | null {
1736
+ const pairs: [string, string][] = [];
1737
+ let at = skipSpillWhitespace(text, 0);
1738
+ while (at < text.length) {
1739
+ if (text.startsWith(SPILL_TOOL_CLOSE, at)) {
1740
+ at = skipSpillWhitespace(text, at + SPILL_TOOL_CLOSE.length);
1741
+ return at >= text.length ? pairs : null;
1742
+ }
1743
+ if (!text.startsWith(SPILL_KEY_OPEN, at)) return null;
1744
+ const keyStart = at + SPILL_KEY_OPEN.length;
1745
+ const keyEnd = text.indexOf(SPILL_KEY_CLOSE, keyStart);
1746
+ if (keyEnd === -1) return null;
1747
+ const key = text.slice(keyStart, keyEnd);
1748
+ if (!SPILL_KEY_PATTERN.test(key)) return null;
1749
+ at = skipSpillWhitespace(text, keyEnd + SPILL_KEY_CLOSE.length);
1750
+ if (!text.startsWith(SPILL_VALUE_OPEN, at)) return null;
1751
+ at += SPILL_VALUE_OPEN.length;
1752
+ const { end, next } = findSpillValueEnd(text, at);
1753
+ pairs.push([key, text.slice(at, end)]);
1754
+ at = skipSpillWhitespace(text, next);
1755
+ }
1756
+ return pairs;
1757
+ }
1758
+
1759
+ /**
1760
+ * Splits a contaminated string value at the earliest spill boundary: a
1761
+ * mistyped `</arg_key>` closer or an inlined next pair. Returns null when no
1762
+ * boundary yields a cleanly parseable tail.
1763
+ */
1764
+ function splitSpilledValue(text: string): SpillSplit | null {
1765
+ let wrong = text.indexOf(SPILL_KEY_CLOSE);
1766
+ let open = text.indexOf(SPILL_KEY_OPEN);
1767
+ while (wrong !== -1 || open !== -1) {
1768
+ if (wrong !== -1 && (open === -1 || wrong < open)) {
1769
+ const pairs = parseSpilledPairs(text.slice(wrong + SPILL_KEY_CLOSE.length));
1770
+ if (pairs) return { head: text.slice(0, wrong), pairs };
1771
+ wrong = text.indexOf(SPILL_KEY_CLOSE, wrong + 1);
1772
+ continue;
1773
+ }
1774
+ if (isSpillPairStart(text, open)) {
1775
+ const pairs = parseSpilledPairs(text.slice(open));
1776
+ if (pairs && pairs.length > 0) return { head: text.slice(0, open).trimEnd(), pairs };
1777
+ }
1778
+ open = text.indexOf(SPILL_KEY_OPEN, open + 1);
1779
+ }
1780
+ return null;
1781
+ }
1782
+
1783
+ /**
1784
+ * Repairs native tool-call arguments contaminated by in-band
1785
+ * `<arg_key>`/`<arg_value>` syntax. Some providers parse owned tool-call
1786
+ * formats server-side; when the model mistypes or omits an `</arg_value>`
1787
+ * closer, every following pair is swallowed into one string argument, e.g.
1788
+ * `op: "done</arg_key>\n<arg_key>task</arg_key>\n<arg_value>…"`. Truncates
1789
+ * each contaminated top-level string at its spill boundary and restores the
1790
+ * swallowed pairs as sibling arguments (never overwriting existing keys).
1791
+ *
1792
+ * Only invoked after validation and every coercion pass fail, so valid calls
1793
+ * whose string content legitimately contains tag-like text are never touched.
1794
+ */
1795
+ function healInbandArgSpill(value: unknown): { value: unknown; changed: boolean } {
1796
+ if (!isPlainRecord(value)) return { value, changed: false };
1797
+ let changed = false;
1798
+ const out: Record<string, unknown> = { ...value };
1799
+ const recovered: [string, string][] = [];
1800
+ for (const key in value) {
1801
+ const entry = value[key];
1802
+ if (typeof entry !== "string") continue;
1803
+ if (!entry.includes(SPILL_KEY_OPEN) && !entry.includes(SPILL_KEY_CLOSE)) continue;
1804
+ const split = splitSpilledValue(entry);
1805
+ if (!split) continue;
1806
+ out[key] = split.head;
1807
+ recovered.push(...split.pairs);
1808
+ changed = true;
1809
+ }
1810
+ if (!changed) return { value, changed: false };
1811
+ for (const [key, entry] of recovered) {
1812
+ if (!(key in out)) out[key] = entry;
1813
+ }
1814
+ return { value: out, changed: true };
1815
+ }
1816
+
1662
1817
  const MAX_COERCION_PASSES = 5;
1663
1818
 
1664
1819
  /**
@@ -1786,7 +1941,67 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1786
1941
  let result = validateContext(ctx, normalizedArgs);
1787
1942
  if (result.success) return result.value as ToolCall["arguments"];
1788
1943
 
1944
+ const coercionOutcome = runCoercionPasses(ctx, normalizedArgs, result);
1945
+ normalizedArgs = coercionOutcome.args;
1946
+ changed ||= coercionOutcome.changed;
1947
+ result = coercionOutcome.result;
1948
+ if (result.success) return result.value as ToolCall["arguments"];
1949
+
1950
+ // Last resort: some providers parse in-band tool-call syntax server-side,
1951
+ // and a mistyped/missing `</arg_value>` closer inlines the remaining pairs
1952
+ // into one string argument. Gated on validation failure so valid calls
1953
+ // with tag-like string content are never rewritten.
1954
+ const spillHeal = healInbandArgSpill(normalizedArgs);
1955
+ if (spillHeal.changed) {
1956
+ normalizedArgs = spillHeal.value;
1957
+ changed = true;
1958
+ result = validateContext(ctx, normalizedArgs);
1959
+ if (!result.success) {
1960
+ const healedOutcome = runCoercionPasses(ctx, normalizedArgs, result);
1961
+ normalizedArgs = healedOutcome.args;
1962
+ result = healedOutcome.result;
1963
+ }
1964
+ if (result.success) return result.value as ToolCall["arguments"];
1965
+ }
1966
+
1967
+ // Format validation errors nicely. The header phrase is asserted by
1968
+ // existing tests; the detailed body is informational.
1969
+ const errors = result.messages.join("\n") || "Unknown validation error";
1970
+
1971
+ // Truncate long per-field strings: the full payload (potentially hundreds
1972
+ // of KB for write/edit-class calls) would otherwise round-trip back to the
1973
+ // model inside the tool error.
1974
+ const receivedArgs = changed
1975
+ ? {
1976
+ original: truncateArgsForError(originalArgs),
1977
+ normalized: truncateArgsForError(normalizedArgs),
1978
+ }
1979
+ : truncateArgsForError(originalArgs);
1980
+
1981
+ const errorMessage = `Validation failed for tool "${
1982
+ toolCall.name
1983
+ }":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
1984
+
1985
+ throw new AIError.ValidationError(errorMessage);
1986
+ }
1987
+
1988
+ /**
1989
+ * Runs up to {@link MAX_COERCION_PASSES} issue-driven coercion rounds,
1990
+ * re-applying the schema normalizations after each round because a coercion
1991
+ * may unwrap JSON-string containers and expose fields the pre-validation
1992
+ * passes could not reach.
1993
+ */
1994
+ function runCoercionPasses(
1995
+ ctx: ValidationContext,
1996
+ args: unknown,
1997
+ initial: ContextValidationResult,
1998
+ ): { args: unknown; result: ContextValidationResult; changed: boolean } {
1999
+ const { json } = ctx;
2000
+ let normalizedArgs = args;
2001
+ let result = initial;
2002
+ let changed = false;
1789
2003
  for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) {
2004
+ if (result.success) break;
1790
2005
  const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues);
1791
2006
  if (!coercion.changed) break;
1792
2007
 
@@ -1840,26 +2055,6 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1840
2055
  }
1841
2056
 
1842
2057
  result = validateContext(ctx, normalizedArgs);
1843
- if (result.success) return result.value as ToolCall["arguments"];
1844
2058
  }
1845
-
1846
- // Format validation errors nicely. The header phrase is asserted by
1847
- // existing tests; the detailed body is informational.
1848
- const errors = result.messages.join("\n") || "Unknown validation error";
1849
-
1850
- // Truncate long per-field strings: the full payload (potentially hundreds
1851
- // of KB for write/edit-class calls) would otherwise round-trip back to the
1852
- // model inside the tool error.
1853
- const receivedArgs = changed
1854
- ? {
1855
- original: truncateArgsForError(originalArgs),
1856
- normalized: truncateArgsForError(normalizedArgs),
1857
- }
1858
- : truncateArgsForError(originalArgs);
1859
-
1860
- const errorMessage = `Validation failed for tool "${
1861
- toolCall.name
1862
- }":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
1863
-
1864
- throw new AIError.ValidationError(errorMessage);
2059
+ return { args: normalizedArgs, result, changed };
1865
2060
  }