@oh-my-pi/pi-ai 18.4.8 → 18.4.10
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 +20 -30
- package/dist/types/auth/affinity.d.ts +5 -0
- package/dist/types/auth/pool.d.ts +3 -1
- package/dist/types/auth/sqlite-credential-store.d.ts +5 -0
- package/dist/types/auth/types.d.ts +2 -0
- package/dist/types/providers/transform-messages.d.ts +7 -0
- package/dist/types/utils/event-stream.d.ts +8 -0
- package/dist/types/utils/http-inspector.d.ts +14 -0
- package/dist/types/utils/idle-iterator.d.ts +11 -8
- package/dist/types/utils/tool-call-arguments.d.ts +9 -0
- package/dist/types/utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth/affinity.ts +60 -8
- package/src/auth/oauth.ts +16 -11
- package/src/auth/pool.ts +6 -2
- package/src/auth/sqlite-credential-store.ts +47 -16
- package/src/auth/types.ts +2 -0
- package/src/dialect/deepseek.ts +2 -5
- package/src/dialect/harmony.ts +2 -6
- package/src/dialect/hermes.ts +2 -5
- package/src/dialect/kimi.ts +2 -6
- package/src/dialect/owned-stream.ts +28 -3
- package/src/dialect/qwen3.ts +2 -5
- package/src/error/flags.ts +2 -0
- package/src/error/rate-limit.ts +11 -0
- package/src/providers/amazon-bedrock.ts +101 -70
- package/src/providers/anthropic.ts +5 -18
- package/src/providers/apple-foundation-models.ts +2 -1
- package/src/providers/cursor.ts +17 -7
- package/src/providers/devin.ts +3 -2
- package/src/providers/gitlab-duo-workflow.ts +3 -8
- package/src/providers/ollama.ts +3 -1
- package/src/providers/openai-codex-responses.ts +6 -4
- package/src/providers/openai-completions.ts +2 -1
- package/src/providers/openai-shared.ts +5 -7
- package/src/providers/register-builtins.ts +1 -1
- package/src/providers/transform-messages.ts +32 -14
- package/src/registry/oauth/factory-droid.ts +21 -1
- package/src/utils/event-stream.ts +13 -0
- package/src/utils/http-inspector.ts +62 -2
- package/src/utils/idle-iterator.ts +29 -24
- package/src/utils/proxy.ts +8 -4
- package/src/utils/tool-call-arguments.ts +32 -0
- package/src/utils/validation.ts +2 -6
- package/src/utils.ts +113 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.10] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Factory Droid login now reports the account's organization error instead of accepting a credential that every request rejects ([#14032](https://github.com/can1357/oh-my-pi/issues/14032)).
|
|
10
|
+
- Fixed Cursor turns being aborted with "Provider stream stalled while waiting for the next event" right after a long local tool finished; the provider now gets a full idle window once local tool work completes ([#13682](https://github.com/can1357/oh-my-pi/pull/13682) by [@eggpeat](https://github.com/eggpeat))
|
|
11
|
+
- Tool calls whose final argument JSON is cut off or followed by trailing text are no longer executed from an auto-closed preview. OpenAI Completions, Anthropic, Bedrock, Responses, Codex, Devin, Ollama, Apple, Cursor, GitLab Duo, and the in-band JSON dialects now give such a call the existing parse-error arguments, so the tool is not run and the model receives the parse error and can resend the call ([#13868](https://github.com/can1357/oh-my-pi/pull/13868) by [@alphastorm](https://github.com/alphastorm))
|
|
12
|
+
- Fixed Cursor "prepaid balance is used up" (`USAGE_PRICING_REQUIRED`) failures repeating on the same account instead of rotating to a sibling Cursor credential ([#14053](https://github.com/can1357/oh-my-pi/issues/14053))
|
|
13
|
+
- Sessions no longer get stuck on `400 string_above_max_length` after a model writes its whole tool invocation into the tool name. Tool calls with blank names, names longer than 128 characters, or names containing whitespace or control characters are dropped from replayed history, together with their tool results. This also applies when OpenAI Responses replays its stored native history ([#13985](https://github.com/can1357/oh-my-pi/pull/13985) by [@Xytronix](https://github.com/Xytronix)).
|
|
14
|
+
- Fixed Bedrock Converse requests failing with a "bound to a different conversation" 400 after the system prompt changed under signed thinking: the request is retried once without replayed reasoning ([#14019](https://github.com/can1357/oh-my-pi/pull/14019) by [@nick-maderight](https://github.com/nick-maderight))
|
|
15
|
+
|
|
16
|
+
## [18.4.9] - 2026-10-01
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed rejected HTTP 400 requests from consuming unbounded disk space by automatically cleaning up old request logs and enforcing a size limit.
|
|
21
|
+
- Fixed unnecessary credential and session updates that could trigger needless authentication reloads in other running processes.
|
|
22
|
+
- Fixed context-overflow recovery for Strata requests that exceed the model context limit but return no usage information.
|
|
23
|
+
|
|
5
24
|
## [18.4.6] - 2026-10-01
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
@@ -2287,33 +2306,4 @@
|
|
|
2287
2306
|
- Fixed tool schema conversion for non-Cloud Code Assist Google Gemini models by normalizing parameters with `normalizeSchemaForGoogle` to prevent un-normalized schema properties (such as `additionalProperties: false` or type arrays) from causing Gemini API errors.
|
|
2288
2307
|
- Fixed OpenAI-family request builders dropping forced named `tool_choice` directives when the named tool is absent from the serialized `tools` array, preventing spec-strict providers from rejecting self-inconsistent requests. ([#1701](https://github.com/can1357/oh-my-pi/issues/1701))
|
|
2289
2308
|
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
### Added
|
|
2293
|
-
|
|
2294
|
-
- Added `GITLAB_CLIENT_ID` and `GITLAB_REDIRECT_URI` env-var overrides for the GitLab Duo OAuth login flow so users running with their own GitLab OAuth application can replace the bundled credentials when GitLab rejects the bundled `client_id`'s redirect URI. Setting `GITLAB_REDIRECT_URI` also disables the random-port fallback (strict OAuth providers reject mismatched URIs anyway). ([#2424](https://github.com/can1357/oh-my-pi/issues/2424))
|
|
2295
|
-
- Added `AuthStorage.listStoredCredentials()` and `AuthStorage.removeCredential()` for per-account credential management.
|
|
2296
|
-
|
|
2297
|
-
### Changed
|
|
2298
|
-
|
|
2299
|
-
- Replaced the OpenAI SDK client usage in `openai-completions`, `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` with the new internal `postOpenAIStream` OpenAI-wire JSON/SSE transport
|
|
2300
|
-
|
|
2301
|
-
### Fixed
|
|
2302
|
-
|
|
2303
|
-
- Fixed streaming providers to cancel upstream model requests when the client closes the response body, so interrupted SSE sessions stop instead of continuing in the background
|
|
2304
|
-
- Fixed: provider request builders treat unknown `model.maxTokens` (`null`) as "no model cap" instead of coercing to `0` via `Math.min`; Anthropic falls back to the 64k Claude-Code cap for its required `max_tokens`.
|
|
2305
|
-
- Fixed transient stream failures on OpenAI-compatible providers by retrying HTTP 408/429/5xx responses and transient network errors with Retry-After/quota-hint aware backoff
|
|
2306
|
-
- Fixed SSE stream handling for OpenAI-compatible responses by parsing wire-level JSON frames directly and honoring `[DONE]` termination
|
|
2307
|
-
- Fixed stream error handling for OpenAI-compatible providers by preserving structured HTTP status/headers and response body details from failed requests for retry and strict-tool fallback logic
|
|
2308
|
-
- Fixed OpenAI-compat streams ending with a bare `finish_reason: "error"` (gateways like OpenRouter reporting upstream failures, e.g. Gemini `MALFORMED_FUNCTION_CALL`) surfacing as a non-retryable `Provider finish_reason: error`. The reason is now mapped to `Provider returned error finish_reason`, which the session retry classifier recognizes as transient, so the turn auto-retries instead of stopping with a pinned error banner.
|
|
2309
|
-
- Fixed `SqliteAuthCredentialStore.open()` crashing with `SQLITE_BUSY_RECOVERY` (errno 261) when several `omp --session` panes restore concurrently after an unclean shutdown: `PRAGMA busy_timeout = 5000` now runs as a standalone statement BEFORE `PRAGMA journal_mode=WAL` (the first lock-taking statement during WAL recovery), and `open()` retries the BUSY family — `SQLITE_BUSY`, `SQLITE_BUSY_RECOVERY`, `SQLITE_BUSY_SNAPSHOT`, `SQLITE_BUSY_TIMEOUT` — with bounded exponential backoff. The exhausted-retry error message includes the DB path. Exported `isSqliteBusyError(err)` for callers that need the same classifier ([#2421](https://github.com/can1357/oh-my-pi/issues/2421)).
|
|
2310
|
-
- Fixed MiniMax-M3 OpenAI-compatible streams rendering reasoning twice when the same chunk carried both `<think>…</think>` content and structured `reasoning_content`; structured reasoning now wins and cumulative MiniMax reasoning snapshots are collapsed to deltas. ([#2433](https://github.com/can1357/oh-my-pi/issues/2433))
|
|
2311
|
-
- Fixed Gemini turns silently halting the agent when the model returned `finishReason: STOP` with only an empty (or whitespace-only) text part and no tool call — the well-known "empty response" failure. All Google surfaces (public Generative Language `streamGoogle`, Vertex `streamGoogleVertex`, and Cloud Code Assist `google-gemini-cli`/`google-antigravity`) now classify such a turn as empty via the shared `hasMeaningfulGoogleContent` check and retry it up to `MAX_EMPTY_STREAM_RETRIES` times before surfacing an error. The Cloud Code Assist path previously had an empty-stream retry that never fired for this case (its `hasContent` flag counted an empty-string text part as content), and the public/Vertex path had no retry at all; the retry now emits a single `start` event so no duplicate partial message leaks downstream.
|
|
2312
|
-
|
|
2313
|
-
## [15.12.1] - 2026-06-12
|
|
2314
|
-
|
|
2315
|
-
### Added
|
|
2316
|
-
|
|
2317
|
-
- Added the optional `ToolResultMessage.useless` flag: tools can declare a finished result contextually useless (zero matches, elapsed wait) so compaction passes may elide it once consumed. Never serialized to provider wire formats and never set together with `isError`.
|
|
2318
|
-
|
|
2319
|
-
Older entries are archived in [packages\ai\CHANGELOG.md@cd762117522c](https://github.com/can1357/oh-my-pi/blob/cd762117522cc4122cd45b8d2d14de5f7be5133b/packages\ai\CHANGELOG.md).
|
|
2309
|
+
Older entries are archived in [packages/ai/CHANGELOG.md@edb740cbad49](https://github.com/can1357/oh-my-pi/blob/edb740cbad499dbc96f8b5b46ebf78f70d6af4d0/packages/ai/CHANGELOG.md).
|
|
@@ -26,6 +26,11 @@ export declare class SessionAffinity implements SessionsApi {
|
|
|
26
26
|
* `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
|
|
27
27
|
* it defaults to now for live selections. Automatic re-recording of the same
|
|
28
28
|
* durable row preserves an explicit user pin.
|
|
29
|
+
*
|
|
30
|
+
* The in-memory sticky is always exact. The persisted row is rewritten only
|
|
31
|
+
* when the credential, its type, or explicitness changes, or when the stored
|
|
32
|
+
* last-use drifts by {@link SESSION_STICKY_PERSIST_INTERVAL_MS} — per-request
|
|
33
|
+
* rewrites were pure database churn.
|
|
29
34
|
*/
|
|
30
35
|
record(provider: string, sessionId: string | undefined, type: AuthCredential["type"], index: number, lastUsedAtMs?: number, explicit?: boolean): void;
|
|
31
36
|
/** Retrieves the last credential used by a session. */
|
|
@@ -116,7 +116,9 @@ export declare class CredentialPool implements CredentialsApi {
|
|
|
116
116
|
/**
|
|
117
117
|
* Persist a refreshed credential by id only while the row still matches this
|
|
118
118
|
* process's snapshot. A peer rotation wins the CAS and is reloaded instead of
|
|
119
|
-
* being overwritten after this process releases its refresh lease.
|
|
119
|
+
* being overwritten after this process releases its refresh lease. An
|
|
120
|
+
* unchanged credential still runs the CAS check (stores skip rewriting
|
|
121
|
+
* identical bytes), so peer rotations are detected without churning the row.
|
|
120
122
|
*
|
|
121
123
|
* Returns the row's current index, or -1 when it was disabled or removed.
|
|
122
124
|
*/
|
|
@@ -36,6 +36,11 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
36
36
|
listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]>;
|
|
37
37
|
replaceAuthCredentials(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
|
|
38
38
|
upsertAuthCredential(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Rewrites a row only when its persisted bytes change: an identical write would
|
|
41
|
+
* still bump `updated_at` and `auth_change_revision`, making every peer process
|
|
42
|
+
* reload credentials for nothing.
|
|
43
|
+
*/
|
|
39
44
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
40
45
|
tryUpdateAuthCredentialIfMatches(id: number, expectedData: string, credential: AuthCredential, lease?: CredentialRefreshLeaseFence): boolean;
|
|
41
46
|
deleteAuthCredential(id: number, disabledCause: string): Promise<boolean>;
|
|
@@ -515,6 +515,8 @@ export interface OAuthAccountSummary {
|
|
|
515
515
|
orgName?: string;
|
|
516
516
|
/** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */
|
|
517
517
|
active: boolean;
|
|
518
|
+
/** Last use recorded on the session sticky; set only on the `active` account. */
|
|
519
|
+
lastUsedAtMs?: number;
|
|
518
520
|
}
|
|
519
521
|
/** Scope a matching-key invalidation to a session or signal. */
|
|
520
522
|
export interface InvalidateCredentialMatchingOptions {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { Api, AssistantMessage, Message, Model } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a tool-call name cannot belong to any declared tool: missing, empty,
|
|
4
|
+
* longer than OpenAI's 128-character replay limit, or containing whitespace or
|
|
5
|
+
* control characters (tool schemas never allow them; their presence means the
|
|
6
|
+
* name slot carries invocation text).
|
|
7
|
+
*/
|
|
8
|
+
export declare function isMalformedToolCallName(name: unknown): boolean;
|
|
2
9
|
/**
|
|
3
10
|
* Normalize tool call ID for cross-provider compatibility.
|
|
4
11
|
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
|
@@ -2,6 +2,12 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
|
|
|
2
2
|
/** Anything a stream watchdog can consult for in-flight consumer-side local work. */
|
|
3
3
|
export interface LocalWorkSource {
|
|
4
4
|
readonly hasPendingLocalWork: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Epoch ms at which tracked local work last drained to zero, or 0 if none
|
|
7
|
+
* has completed. The provider cannot answer until it receives the local
|
|
8
|
+
* result, so idle watchdogs measure provider silence from this instant.
|
|
9
|
+
*/
|
|
10
|
+
readonly localWorkSettledAt: number;
|
|
5
11
|
}
|
|
6
12
|
export declare class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
7
13
|
#private;
|
|
@@ -35,6 +41,8 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
35
41
|
result(): Promise<R>;
|
|
36
42
|
/** True while local work tracked via {@link trackLocalWork} — on this stream or a forwarded delegate — is pending. */
|
|
37
43
|
get hasPendingLocalWork(): boolean;
|
|
44
|
+
/** Latest {@link LocalWorkSource.localWorkSettledAt} across this stream and a forwarded delegate. */
|
|
45
|
+
get localWorkSettledAt(): number;
|
|
38
46
|
/**
|
|
39
47
|
* Count `source`'s pending local work as this stream's own. Used when this
|
|
40
48
|
* stream forwards another's events (Cursor discovered-id retry) so the
|
|
@@ -39,6 +39,20 @@ export declare function shouldDumpRejectedRequest(error: unknown): boolean;
|
|
|
39
39
|
*/
|
|
40
40
|
export declare function stripRawHttpRequestDiagnostics(message: string): string;
|
|
41
41
|
export declare function appendRawHttpRequestDumpFor400(message: string, error: unknown, dump: RawHttpRequestDump | undefined): Promise<string>;
|
|
42
|
+
export interface HttpRequestDumpRetention {
|
|
43
|
+
/** Dump file name never deleted (the one just written and referenced by the error). */
|
|
44
|
+
keep?: string;
|
|
45
|
+
/** Dumps last modified longer ago than this are deleted (default 7 days). */
|
|
46
|
+
maxAgeMs?: number;
|
|
47
|
+
/** Oldest remaining dumps are deleted until the directory totals at most this (default 64 MiB). */
|
|
48
|
+
maxTotalBytes?: number;
|
|
49
|
+
now?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Bound the rejected-request dump directory: delete `*.json` dumps older than
|
|
53
|
+
* the age limit, then the oldest survivors until the total fits the size cap.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pruneHttpRequestDumps(dir: string, retention?: HttpRequestDumpRetention): Promise<void>;
|
|
42
56
|
export declare function finalizeErrorMessage(error: unknown, rawRequestDump: RawHttpRequestDump | undefined, capturedErrorResponse?: CapturedHttpErrorResponse): Promise<string>;
|
|
43
57
|
/**
|
|
44
58
|
* Rewrite error message for GitHub Copilot request failures.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LocalWorkSource } from "./event-stream.js";
|
|
1
2
|
/**
|
|
2
3
|
* Returns the idle timeout used for provider streaming transports.
|
|
3
4
|
*
|
|
@@ -86,15 +87,17 @@ export interface IdleTimeoutIteratorOptions {
|
|
|
86
87
|
*/
|
|
87
88
|
isProgressItem?: (item: unknown) => boolean;
|
|
88
89
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
90
|
+
* Consumer-side local work in flight for the stream: the provider transport
|
|
91
|
+
* is waiting on a server-requested local tool bridge (e.g. the Cursor exec
|
|
92
|
+
* channel) before anything can flow upstream again. While work is pending,
|
|
93
|
+
* an expired idle / first-item deadline slides forward instead of aborting —
|
|
94
|
+
* the silence is ours, not a provider stall. The provider cannot respond
|
|
95
|
+
* before it receives the local result, so both deadlines are measured from
|
|
96
|
+
* no earlier than `localWorkSettledAt`: a tool that finishes just before the
|
|
97
|
+
* deadline gets a full budget, and a provider that stalls afterwards is
|
|
98
|
+
* still caught.
|
|
96
99
|
*/
|
|
97
|
-
|
|
100
|
+
localWork?: LocalWorkSource;
|
|
98
101
|
/**
|
|
99
102
|
* Cancel iteration as soon as this signal aborts. Required for caller-driven
|
|
100
103
|
* cancellation (ESC) when the underlying transport does not surface signal
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolCall } from "../types.js";
|
|
2
|
+
/** Final arguments must not reuse the streaming parser's auto-closed preview. */
|
|
3
|
+
export declare function parseToolCallArguments(json: string | undefined): ToolCall["arguments"];
|
|
4
|
+
/** Longest raw argument text kept in the parse-error diagnostic. */
|
|
5
|
+
export declare const INVALID_ARGUMENTS_RAW_LIMIT = 512;
|
|
6
|
+
/** Bound raw argument text for a diagnostic. Text this function already bounded is returned unchanged. */
|
|
7
|
+
export declare function boundRawToolArguments(raw: string): string;
|
|
8
|
+
/** Preserve a bounded diagnostic, never any partially recovered executable keys. */
|
|
9
|
+
export declare function invalidToolCallArguments(raw: string, error: unknown): ToolCall["arguments"];
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -63,6 +63,22 @@ export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(it
|
|
|
63
63
|
* Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
|
|
64
64
|
*/
|
|
65
65
|
export declare function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: ResponseInput): ResponseInput;
|
|
66
|
+
/**
|
|
67
|
+
* Drop each native tool call with a malformed name and only the matching
|
|
68
|
+
* `function_call_output` / `custom_tool_call_output` in the same window.
|
|
69
|
+
*
|
|
70
|
+
* `call_id` is not unique across a snapshot. Pair the same way
|
|
71
|
+
* `sanitizeMalformedToolCalls` does for transcript messages: a per-id FIFO of
|
|
72
|
+
* malformed-ness, keyed by function vs custom, cleared at user/developer and
|
|
73
|
+
* other non-result client boundaries so a missing result cannot eat the next
|
|
74
|
+
* window's output. Returns `items` itself when nothing is malformed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function dropMalformedOpenAIResponsesToolCalls<T extends {
|
|
77
|
+
type?: unknown;
|
|
78
|
+
call_id?: unknown;
|
|
79
|
+
name?: unknown;
|
|
80
|
+
role?: unknown;
|
|
81
|
+
}>(items: readonly T[]): T[];
|
|
66
82
|
export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
|
|
67
83
|
export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
|
|
68
84
|
export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.4.
|
|
3
|
+
"version": "18.4.10",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.4.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
158
|
+
"@oh-my-pi/omptype": "18.4.10",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.4.10",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.4.10",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.4.10",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.4.10"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/affinity.ts
CHANGED
|
@@ -7,6 +7,21 @@ import type { KeyOverrides } from "./cascade";
|
|
|
7
7
|
|
|
8
8
|
/** Prefix for persisted session-to-credential affinity. */
|
|
9
9
|
export const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
|
|
10
|
+
/** Persisted sticky rows live this long past their last use. */
|
|
11
|
+
const SESSION_STICKY_TTL_SEC = 30 * 24 * 60 * 60;
|
|
12
|
+
/**
|
|
13
|
+
* Same-credential re-records rewrite the persisted row at most this often. The
|
|
14
|
+
* in-memory sticky stays exact; only the copy other processes resume from lags.
|
|
15
|
+
*/
|
|
16
|
+
const SESSION_STICKY_PERSIST_INTERVAL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** What this process last wrote (or read) for one persisted sticky row. */
|
|
19
|
+
type PersistedSticky = {
|
|
20
|
+
type: AuthCredential["type"];
|
|
21
|
+
credentialId: number;
|
|
22
|
+
explicit: boolean;
|
|
23
|
+
lastUsedAtMs: number;
|
|
24
|
+
};
|
|
10
25
|
|
|
11
26
|
/** A session's pinned credential (resolved index + durable row id). */
|
|
12
27
|
export type SessionCredential = {
|
|
@@ -22,6 +37,8 @@ export type SessionCredential = {
|
|
|
22
37
|
export class SessionAffinity implements SessionsApi {
|
|
23
38
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
24
39
|
#sessionLastCredential: Map<string, Map<string, SessionCredential>> = new Map();
|
|
40
|
+
/** Persisted sticky rows keyed by cache key, so unchanged re-records skip the write. */
|
|
41
|
+
#persistedSticky: Map<string, PersistedSticky> = new Map();
|
|
25
42
|
#store: AuthCredentialStore;
|
|
26
43
|
#pool: CredentialPool;
|
|
27
44
|
#overrides: KeyOverrides;
|
|
@@ -35,8 +52,12 @@ export class SessionAffinity implements SessionsApi {
|
|
|
35
52
|
/** Drop every pin for a provider, both in memory and in the persisted cache. */
|
|
36
53
|
clearProvider(provider: string): void {
|
|
37
54
|
this.#sessionLastCredential.delete(provider);
|
|
55
|
+
const prefix = `${SESSION_STICKY_CACHE_PREFIX}${provider}:`;
|
|
56
|
+
for (const cacheKey of this.#persistedSticky.keys()) {
|
|
57
|
+
if (cacheKey.startsWith(prefix)) this.#persistedSticky.delete(cacheKey);
|
|
58
|
+
}
|
|
38
59
|
try {
|
|
39
|
-
this.#store.deleteCachePrefix?.(
|
|
60
|
+
this.#store.deleteCachePrefix?.(prefix);
|
|
40
61
|
} catch (err) {
|
|
41
62
|
logger.debug("Failed to clear provider session sticky credentials from persistent store cache", { err });
|
|
42
63
|
}
|
|
@@ -52,6 +73,11 @@ export class SessionAffinity implements SessionsApi {
|
|
|
52
73
|
* `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
|
|
53
74
|
* it defaults to now for live selections. Automatic re-recording of the same
|
|
54
75
|
* durable row preserves an explicit user pin.
|
|
76
|
+
*
|
|
77
|
+
* The in-memory sticky is always exact. The persisted row is rewritten only
|
|
78
|
+
* when the credential, its type, or explicitness changes, or when the stored
|
|
79
|
+
* last-use drifts by {@link SESSION_STICKY_PERSIST_INTERVAL_MS} — per-request
|
|
80
|
+
* rewrites were pure database churn.
|
|
55
81
|
*/
|
|
56
82
|
record(
|
|
57
83
|
provider: string,
|
|
@@ -80,14 +106,29 @@ export class SessionAffinity implements SessionsApi {
|
|
|
80
106
|
sessionMap.set(sessionId, sessionCredential);
|
|
81
107
|
this.#sessionLastCredential.set(provider, sessionMap);
|
|
82
108
|
|
|
109
|
+
if (credentialId === undefined) return;
|
|
110
|
+
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
111
|
+
const expiresAtSec = Math.floor(nowMs / 1000) + SESSION_STICKY_TTL_SEC;
|
|
112
|
+
const persisted = this.#persistedSticky.get(cacheKey);
|
|
113
|
+
if (
|
|
114
|
+
persisted &&
|
|
115
|
+
persisted.type === type &&
|
|
116
|
+
persisted.credentialId === credentialId &&
|
|
117
|
+
persisted.explicit === isExplicit &&
|
|
118
|
+
Math.abs(nowMs - persisted.lastUsedAtMs) < SESSION_STICKY_PERSIST_INTERVAL_MS
|
|
119
|
+
) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
83
122
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
123
|
+
this.#store.setCache(cacheKey, JSON.stringify(sessionCredential), expiresAtSec);
|
|
124
|
+
this.#persistedSticky.set(cacheKey, {
|
|
125
|
+
type,
|
|
126
|
+
credentialId,
|
|
127
|
+
explicit: isExplicit,
|
|
128
|
+
lastUsedAtMs: nowMs,
|
|
129
|
+
});
|
|
90
130
|
} catch (err) {
|
|
131
|
+
this.#persistedSticky.delete(cacheKey);
|
|
91
132
|
logger.debug("Failed to write session sticky credential to persistent store cache", { err });
|
|
92
133
|
}
|
|
93
134
|
}
|
|
@@ -122,12 +163,14 @@ export class SessionAffinity implements SessionsApi {
|
|
|
122
163
|
const stored = this.#pool.entries(provider);
|
|
123
164
|
const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
|
|
124
165
|
if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
|
|
166
|
+
this.#persistedSticky.delete(cacheKey);
|
|
125
167
|
this.#store.setCache(cacheKey, "", 0);
|
|
126
168
|
return undefined;
|
|
127
169
|
}
|
|
128
170
|
val.index = actualIndex;
|
|
129
171
|
} else {
|
|
130
172
|
// Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
|
|
173
|
+
this.#persistedSticky.delete(cacheKey);
|
|
131
174
|
this.#store.setCache(cacheKey, "", 0);
|
|
132
175
|
return undefined;
|
|
133
176
|
}
|
|
@@ -143,6 +186,14 @@ export class SessionAffinity implements SessionsApi {
|
|
|
143
186
|
lastUsedAtMs: val.lastUsedAtMs,
|
|
144
187
|
...(val.explicit === true ? { explicit: true } : {}),
|
|
145
188
|
};
|
|
189
|
+
if (typeof val.lastUsedAtMs === "number") {
|
|
190
|
+
this.#persistedSticky.set(cacheKey, {
|
|
191
|
+
type: val.type,
|
|
192
|
+
credentialId: val.credentialId,
|
|
193
|
+
explicit: val.explicit === true,
|
|
194
|
+
lastUsedAtMs: val.lastUsedAtMs,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
146
197
|
sessionMap.set(sessionId, sessionVal);
|
|
147
198
|
return sessionVal;
|
|
148
199
|
}
|
|
@@ -162,8 +213,9 @@ export class SessionAffinity implements SessionsApi {
|
|
|
162
213
|
this.#sessionLastCredential.delete(provider);
|
|
163
214
|
}
|
|
164
215
|
}
|
|
216
|
+
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
217
|
+
this.#persistedSticky.delete(cacheKey);
|
|
165
218
|
try {
|
|
166
|
-
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
167
219
|
this.#store.setCache(cacheKey, "", 0);
|
|
168
220
|
} catch (err) {
|
|
169
221
|
logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
|
package/src/auth/oauth.ts
CHANGED
|
@@ -244,17 +244,22 @@ export class OAuthAccounts implements OAuthApi {
|
|
|
244
244
|
sessionCredential?.type === "oauth"
|
|
245
245
|
? this.#deps.pool.entries(provider)[sessionCredential.index]?.id
|
|
246
246
|
: undefined;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
247
|
+
const activeLastUsedAtMs = activeCredentialId !== undefined ? sessionCredential?.lastUsedAtMs : undefined;
|
|
248
|
+
return this.#getStoredOAuthSelections(provider).map((selection, position) => {
|
|
249
|
+
const active = selection.credentialId === activeCredentialId;
|
|
250
|
+
return {
|
|
251
|
+
position,
|
|
252
|
+
credentialId: selection.credentialId,
|
|
253
|
+
accountId: selection.credential.accountId,
|
|
254
|
+
email: selection.credential.email,
|
|
255
|
+
projectId: selection.credential.projectId,
|
|
256
|
+
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
257
|
+
orgId: selection.credential.orgId,
|
|
258
|
+
orgName: selection.credential.orgName,
|
|
259
|
+
active,
|
|
260
|
+
...(active && activeLastUsedAtMs !== undefined ? { lastUsedAtMs: activeLastUsedAtMs } : {}),
|
|
261
|
+
};
|
|
262
|
+
});
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
/**
|
package/src/auth/pool.ts
CHANGED
|
@@ -467,7 +467,9 @@ export class CredentialPool implements CredentialsApi {
|
|
|
467
467
|
/**
|
|
468
468
|
* Persist a refreshed credential by id only while the row still matches this
|
|
469
469
|
* process's snapshot. A peer rotation wins the CAS and is reloaded instead of
|
|
470
|
-
* being overwritten after this process releases its refresh lease.
|
|
470
|
+
* being overwritten after this process releases its refresh lease. An
|
|
471
|
+
* unchanged credential still runs the CAS check (stores skip rewriting
|
|
472
|
+
* identical bytes), so peer rotations are detected without churning the row.
|
|
471
473
|
*
|
|
472
474
|
* Returns the row's current index, or -1 when it was disabled or removed.
|
|
473
475
|
*/
|
|
@@ -489,7 +491,9 @@ export class CredentialPool implements CredentialsApi {
|
|
|
489
491
|
return latest.findIndex(row => row.id === id);
|
|
490
492
|
}
|
|
491
493
|
if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
|
|
492
|
-
|
|
494
|
+
if (serializeCredential(provider, credential)?.data !== expected?.data) {
|
|
495
|
+
this.#store.updateAuthCredential(id, credential);
|
|
496
|
+
}
|
|
493
497
|
}
|
|
494
498
|
const updated = [...entries];
|
|
495
499
|
updated[index] = { id, credential };
|
|
@@ -88,6 +88,23 @@ type SerializedCredentialRecord = {
|
|
|
88
88
|
identityKey: string | null;
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
/** Persisted columns compared to skip rewriting a credential row with identical bytes. */
|
|
92
|
+
type CredentialStateRow = {
|
|
93
|
+
provider: string;
|
|
94
|
+
credential_type: string;
|
|
95
|
+
data: string;
|
|
96
|
+
identity_key: string | null;
|
|
97
|
+
disabled_cause: string | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function storesSerializedCredential(row: CredentialStateRow, serialized: SerializedCredentialRecord): boolean {
|
|
101
|
+
return (
|
|
102
|
+
row.data === serialized.data &&
|
|
103
|
+
row.credential_type === serialized.credentialType &&
|
|
104
|
+
(row.identity_key ?? null) === serialized.identityKey
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
const AUTH_SCHEMA_VERSION = 8;
|
|
92
109
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
93
110
|
const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
|
|
@@ -378,6 +395,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
378
395
|
#getCredentialRefreshLeaseStmt: Statement;
|
|
379
396
|
#renewCredentialRefreshLeaseStmt: Statement;
|
|
380
397
|
#releaseCredentialRefreshLeaseStmt: Statement;
|
|
398
|
+
#getCredentialStateStmt: Statement;
|
|
399
|
+
#holdsCredentialRefreshLeaseStmt: Statement;
|
|
381
400
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
382
401
|
#insertUsageHistoryStmt: Statement;
|
|
383
402
|
#lastUsageHistoryStmt: Statement;
|
|
@@ -495,6 +514,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
495
514
|
this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
496
515
|
"DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
|
|
497
516
|
);
|
|
517
|
+
this.#getCredentialStateStmt = this.#db.prepare(
|
|
518
|
+
"SELECT provider, credential_type, data, identity_key, disabled_cause FROM auth_credentials WHERE id = ?",
|
|
519
|
+
);
|
|
520
|
+
this.#holdsCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
521
|
+
"SELECT 1 FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?",
|
|
522
|
+
);
|
|
498
523
|
this.#insertUsageHistoryStmt = this.#db.prepare(
|
|
499
524
|
"INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
500
525
|
);
|
|
@@ -1414,18 +1439,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1414
1439
|
}
|
|
1415
1440
|
}
|
|
1416
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Rewrites a row only when its persisted bytes change: an identical write would
|
|
1444
|
+
* still bump `updated_at` and `auth_change_revision`, making every peer process
|
|
1445
|
+
* reload credentials for nothing.
|
|
1446
|
+
*/
|
|
1417
1447
|
updateAuthCredential(id: number, credential: AuthCredential): void {
|
|
1418
1448
|
try {
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
try {
|
|
1422
|
-
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
1423
|
-
} finally {
|
|
1424
|
-
providerStmt.finalize();
|
|
1425
|
-
}
|
|
1426
|
-
const provider = providerRow?.provider ?? "";
|
|
1449
|
+
const row = this.#getCredentialStateStmt.get(id) as CredentialStateRow | null;
|
|
1450
|
+
const provider = row?.provider ?? "";
|
|
1427
1451
|
const serialized = serializeCredential(provider, credential);
|
|
1428
1452
|
if (!serialized) return;
|
|
1453
|
+
if (row && storesSerializedCredential(row, serialized)) return;
|
|
1429
1454
|
this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
|
|
1430
1455
|
if (provider) {
|
|
1431
1456
|
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
@@ -1441,16 +1466,20 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1441
1466
|
credential: AuthCredential,
|
|
1442
1467
|
lease?: CredentialRefreshLeaseFence,
|
|
1443
1468
|
): boolean {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
try {
|
|
1447
|
-
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
1448
|
-
} finally {
|
|
1449
|
-
providerStmt.finalize();
|
|
1450
|
-
}
|
|
1451
|
-
const provider = providerRow?.provider ?? "";
|
|
1469
|
+
const row = this.#getCredentialStateStmt.get(id) as CredentialStateRow | null;
|
|
1470
|
+
const provider = row?.provider ?? "";
|
|
1452
1471
|
const serialized = serializeCredential(provider, credential);
|
|
1453
1472
|
if (!serialized) return false;
|
|
1473
|
+
if (
|
|
1474
|
+
row &&
|
|
1475
|
+
row.disabled_cause === null &&
|
|
1476
|
+
serialized.data === expectedData &&
|
|
1477
|
+
storesSerializedCredential(row, serialized)
|
|
1478
|
+
) {
|
|
1479
|
+
// The row already holds exactly this credential: the CAS (and lease fence)
|
|
1480
|
+
// matches, but rewriting identical bytes would only churn the revision.
|
|
1481
|
+
return !lease || Boolean(this.#holdsCredentialRefreshLeaseStmt.get(id, lease.owner, lease.nowMs));
|
|
1482
|
+
}
|
|
1454
1483
|
const result = lease
|
|
1455
1484
|
? (this.#updateIfMatchesWithLeaseStmt.run(
|
|
1456
1485
|
serialized.credentialType,
|
|
@@ -2038,6 +2067,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
2038
2067
|
this.#getCredentialRefreshLeaseStmt.finalize();
|
|
2039
2068
|
this.#renewCredentialRefreshLeaseStmt.finalize();
|
|
2040
2069
|
this.#releaseCredentialRefreshLeaseStmt.finalize();
|
|
2070
|
+
this.#getCredentialStateStmt.finalize();
|
|
2071
|
+
this.#holdsCredentialRefreshLeaseStmt.finalize();
|
|
2041
2072
|
this.#db.close();
|
|
2042
2073
|
}
|
|
2043
2074
|
}
|
package/src/auth/types.ts
CHANGED
|
@@ -581,6 +581,8 @@ export interface OAuthAccountSummary {
|
|
|
581
581
|
orgName?: string;
|
|
582
582
|
/** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */
|
|
583
583
|
active: boolean;
|
|
584
|
+
/** Last use recorded on the session sticky; set only on the `active` account. */
|
|
585
|
+
lastUsedAtMs?: number;
|
|
584
586
|
}
|
|
585
587
|
/** Scope a matching-key invalidation to a session or signal. */
|
|
586
588
|
export interface InvalidateCredentialMatchingOptions {
|
package/src/dialect/deepseek.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Message, ToolCall } from "../types";
|
|
3
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
4
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
5
|
import dialectPrompt from "./deepseek.md" with { type: "text" };
|
|
5
6
|
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
|
@@ -477,11 +478,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
477
478
|
#parseArgs(rawArgs: string): Record<string, unknown> {
|
|
478
479
|
const trimmed = rawArgs.trim();
|
|
479
480
|
if (trimmed.length === 0) return {};
|
|
480
|
-
|
|
481
|
-
return asRecord(parseJsonWithRepair<unknown>(trimmed));
|
|
482
|
-
} catch {
|
|
483
|
-
return {};
|
|
484
|
-
}
|
|
481
|
+
return asRecord(parseToolCallArguments(trimmed));
|
|
485
482
|
}
|
|
486
483
|
|
|
487
484
|
#skipWhitespace(): string {
|
package/src/dialect/harmony.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import type { Message, ToolCall } from "../types";
|
|
2
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
3
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
4
|
import dialectPrompt from "./harmony.md" with { type: "text" };
|
|
5
5
|
import {
|
|
@@ -225,11 +225,7 @@ export class HarmonyInbandScanner implements InbandScanner {
|
|
|
225
225
|
#parseArgs(): Record<string, unknown> {
|
|
226
226
|
const raw = this.#toolArgs.trim();
|
|
227
227
|
if (raw.length === 0) return {};
|
|
228
|
-
|
|
229
|
-
return asRecord(parseJsonWithRepair<unknown>(raw));
|
|
230
|
-
} catch {
|
|
231
|
-
return {};
|
|
232
|
-
}
|
|
228
|
+
return asRecord(parseToolCallArguments(raw));
|
|
233
229
|
}
|
|
234
230
|
|
|
235
231
|
#clearBody(resetRawBlock = true): void {
|
package/src/dialect/hermes.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Message, ToolCall } from "../types";
|
|
3
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
4
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
5
|
import dialectPrompt from "./hermes.md" with { type: "text" };
|
|
5
6
|
import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
|
|
@@ -147,11 +148,7 @@ export class HermesInbandScanner implements InbandScanner {
|
|
|
147
148
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
|
|
148
149
|
let args = parsed.arguments;
|
|
149
150
|
if (typeof args === "string") {
|
|
150
|
-
|
|
151
|
-
args = parseJsonWithRepair<unknown>(args);
|
|
152
|
-
} catch {
|
|
153
|
-
args = {};
|
|
154
|
-
}
|
|
151
|
+
args = parseToolCallArguments(args);
|
|
155
152
|
}
|
|
156
153
|
return { name: parsed.name, arguments: asRecord(args) };
|
|
157
154
|
} catch {
|