@oh-my-pi/pi-ai 18.8.5 → 18.8.7
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 +21 -16
- package/dist/types/auth/select.d.ts +3 -3
- package/dist/types/providers/openai-configuration-update.d.ts +5 -2
- package/dist/types/providers/openai-responses.d.ts +1 -0
- package/dist/types/types.d.ts +2 -0
- package/dist/types/utils/glyph-codec.d.ts +1 -1
- package/dist/types/utils/schema/normalize.d.ts +8 -3
- package/package.json +6 -6
- package/src/auth/select.ts +31 -14
- package/src/auth-broker/remote-store.ts +44 -7
- package/src/error/rate-limit.ts +8 -6
- package/src/providers/amazon-bedrock.ts +15 -5
- package/src/providers/anthropic.ts +22 -1
- package/src/providers/openai-codex-responses.ts +26 -11
- package/src/providers/openai-configuration-update.ts +17 -3
- package/src/providers/openai-responses.ts +13 -1
- package/src/types.ts +2 -0
- package/src/utils/glyph-codec.ts +200 -7
- package/src/utils/schema/CONSTRAINTS.md +9 -7
- package/src/utils/schema/fields.ts +20 -0
- package/src/utils/schema/normalize.ts +64 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.7] - 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added routing-session cleanup for OpenAI Responses and Codex while preserving shared provider fallbacks ([#14334](https://github.com/can1357/oh-my-pi/pull/14334) by [@iliaal](https://github.com/iliaal)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Claude Haiku 5.5 requests silently enabling adaptive thinking when reasoning is off, on native Bedrock (main and helper calls) and the Anthropic API; conversations whose earlier effort controls rule out disabled thinking fall back to lowest-effort adaptive thinking instead of failing ([#14996](https://github.com/can1357/oh-my-pi/pull/14996) by [@bse-ai](https://github.com/bse-ai)).
|
|
14
|
+
- Fixed `/session pin` being ignored when every stored account is quota-blocked, which routed the next request to a different exhausted account instead of the pinned one ([#14997](https://github.com/can1357/oh-my-pi/issues/14997)).
|
|
15
|
+
- Fixed `minimax-code-cn` sessions staying pinned to a key whose Token Plan quota is exhausted (`用量上限` 429) instead of rotating to a sibling credential ([#15053](https://github.com/can1357/oh-my-pi/issues/15053)).
|
|
16
|
+
|
|
17
|
+
## [18.8.6] - 2026-10-08
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed Google Gemini and Cloud Code Assist (Antigravity) requests failing when tool schemas contain unsupported JSON Schema keywords or fields that allow multiple types.
|
|
22
|
+
- Fixed auth broker account selection and usage-limit enforcement to consistently use the selected account’s quota when multiple accounts are present.
|
|
23
|
+
- Fixed Anthropic-family model streaming so encoded marker tokens are decoded correctly in text, tool-call updates, partial messages, and completed tool calls.
|
|
24
|
+
|
|
5
25
|
## [18.8.5] - 2026-10-08
|
|
6
26
|
|
|
7
27
|
### Added
|
|
@@ -2333,19 +2353,4 @@
|
|
|
2333
2353
|
- Fixed OpenAI-compatible Ollama completions that return empty `finish_reason:length` after filling `num_ctx` so they surface an actionable context-window error instead of an empty length stop. ([#2774](https://github.com/can1357/oh-my-pi/issues/2774))
|
|
2334
2354
|
- Fixed Codex browser login issuing credentials for the `opencode` OAuth originator while OMP requests identify as `pi`, which could make the first authenticated Codex request return 401 ([#2696](https://github.com/can1357/oh-my-pi/issues/2696)).
|
|
2335
2355
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
### Added
|
|
2339
|
-
|
|
2340
|
-
- Added Umans AI Coding Plan API-key login support and `UMANS_AI_CODING_PLAN_API_KEY` environment fallback ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)).
|
|
2341
|
-
|
|
2342
|
-
### Fixed
|
|
2343
|
-
|
|
2344
|
-
- Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
|
|
2345
|
-
- Fixed OpenAI-compatible chat-completions streams that send object-shaped tool arguments in fragments by deep-merging nested objects and task arrays instead of replacing earlier chunks. ([#2617](https://github.com/can1357/oh-my-pi/issues/2617))
|
|
2346
|
-
- Fixed OpenAI Responses strict-mode tool schema normalization for nullable enum MCP parameters so enum constraints are distributed to matching `anyOf` branches instead of being copied onto the `null` branch. ([#1835](https://github.com/can1357/oh-my-pi/issues/1835))
|
|
2347
|
-
- Fixed Cursor provider formatting tool errors with the same `[Tool Result]` prefix as successful results, causing Composer models to misinterpret error messages (e.g. "Pattern must not be empty") as directives over long conversations. Errors now use a `[Tool Error]` prefix so the model can distinguish failures from successes in the prompt history. ([#1853](https://github.com/can1357/oh-my-pi/pull/1853))
|
|
2348
|
-
- Fixed `validateToolArguments` silently accepting JSON-encoded array strings (e.g. `'["a","b"]'`) against `union(string, array<string>)` schemas — providers that double-serialize tool-call arguments (Z.AI / GLM) caused tools like `search` to receive the literal `["a","b"]` as a single path, producing zero matches (single element) or glob parse errors (multi-element). A new pre-validation pass parses JSON-array-shaped strings when the schema explicitly accepts both shapes. ([#1788](https://github.com/can1357/oh-my-pi/issues/1788))
|
|
2349
|
-
- Fixed Anthropic thinking summaries that arrive wrapped in literal `<thinking>` tags so advisor/raw transcript dumps do not render nested thinking tags ([#2695](https://github.com/can1357/oh-my-pi/issues/2695)).
|
|
2350
|
-
|
|
2351
|
-
Older entries are archived in [packages/ai/CHANGELOG.md@d22e34333695](https://github.com/can1357/oh-my-pi/blob/d22e34333695d3dd9de7741ff45903681dba4606/packages/ai/CHANGELOG.md).
|
|
2356
|
+
Older entries are archived in [packages/ai/CHANGELOG.md@0dd6aff5f282](https://github.com/can1357/oh-my-pi/blob/0dd6aff5f2821aec1e5b54c6a458e323667a949b/packages/ai/CHANGELOG.md).
|
|
@@ -71,9 +71,9 @@ export declare class CredentialSelector {
|
|
|
71
71
|
* 1. strict: unblocked credentials only, usage limits respected, plan
|
|
72
72
|
* filter enforced (when any account is confirmed eligible);
|
|
73
73
|
* 2. plan-fitting last resort: same plan filter, but blocked/exhausted
|
|
74
|
-
* accounts are allowed (
|
|
75
|
-
* first) so the caller gets real
|
|
76
|
-
* instead of a missing key;
|
|
74
|
+
* accounts are allowed (an explicit session pin first, then blocked
|
|
75
|
+
* candidates earliest-unblocking first) so the caller gets real
|
|
76
|
+
* usage-limit semantics from the wire instead of a missing key;
|
|
77
77
|
* 3. unfiltered last resort: the plan filter matched nothing usable —
|
|
78
78
|
* skip it and try every account once; the server is the final arbiter
|
|
79
79
|
* of model access.
|
|
@@ -35,16 +35,19 @@ interface EffortTransition<TEffort extends string> {
|
|
|
35
35
|
}
|
|
36
36
|
/** Per-conversation effort baseline and recorded transitions. */
|
|
37
37
|
export interface OpenAIEffortControlState<TEffort extends string = string> {
|
|
38
|
+
sessionId: string;
|
|
38
39
|
baseEffort?: TEffort;
|
|
39
40
|
currentEffort?: TEffort;
|
|
40
41
|
transitions: EffortTransition<TEffort>[];
|
|
41
42
|
}
|
|
42
|
-
export declare function createOpenAIEffortControlState<TEffort extends string>(): OpenAIEffortControlState<TEffort>;
|
|
43
|
+
export declare function createOpenAIEffortControlState<TEffort extends string>(sessionId: string): OpenAIEffortControlState<TEffort>;
|
|
43
44
|
/**
|
|
44
45
|
* Fetch (or create) the control state for one conversation from a provider's
|
|
45
46
|
* bounded per-session map, refreshing its LRU slot.
|
|
46
47
|
*/
|
|
47
|
-
export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string): OpenAIEffortControlState<TEffort>;
|
|
48
|
+
export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string, sessionId: string): OpenAIEffortControlState<TEffort>;
|
|
49
|
+
/** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
|
|
50
|
+
export declare function releaseOpenAIEffortControlSession<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, sessionId: string): void;
|
|
48
51
|
interface AnchorableItem {
|
|
49
52
|
type?: string | null;
|
|
50
53
|
role?: string;
|
|
@@ -91,6 +91,7 @@ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, Open
|
|
|
91
91
|
/** Wire efforts a `configuration_update` can carry: every real tier, never `none`/null. */
|
|
92
92
|
type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
|
|
93
93
|
interface OpenAIResponsesChainState {
|
|
94
|
+
sessionId: string;
|
|
94
95
|
/**
|
|
95
96
|
* Wire params of the last successful turn; never carries
|
|
96
97
|
* `previous_response_id`.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -191,6 +191,8 @@ export declare function parseServiceTier(value: unknown): ServiceTier | undefine
|
|
|
191
191
|
export declare function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined;
|
|
192
192
|
export interface ProviderSessionState {
|
|
193
193
|
close(): void;
|
|
194
|
+
/** Release one routing session after its final request, preserving shared provider fallbacks. */
|
|
195
|
+
releaseSession?(sessionId: string): void;
|
|
194
196
|
}
|
|
195
197
|
export interface ProviderResponseMetadata {
|
|
196
198
|
status: number;
|
|
@@ -4,7 +4,7 @@ import { AssistantMessageEventStream } from "./event-stream.js";
|
|
|
4
4
|
export interface GlyphCodec {
|
|
5
5
|
/** Context with model-bound glyphs encoded and one conditional convention notice. */
|
|
6
6
|
context: Context;
|
|
7
|
-
/** Decodes glyph tokens
|
|
7
|
+
/** Decodes glyph tokens live: text and tool-call deltas, partial snapshots, completed tool calls, and the terminal message. */
|
|
8
8
|
wrap(inner: AssistantMessageEventStream): AssistantMessageEventStream;
|
|
9
9
|
/** Decodes Cursor Pi calls and encodes their provider-bound results. */
|
|
10
10
|
wrapCursorExecHandlers(handlers: CursorExecHandlers): CursorExecHandlers;
|
|
@@ -10,7 +10,11 @@ export interface NormalizeSchemaOptions {
|
|
|
10
10
|
unsupportedFields: (key: string) => boolean;
|
|
11
11
|
normalizeFieldNames: boolean;
|
|
12
12
|
collapseNullFields: boolean;
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Target takes a scalar `type` only: `["T", "null"]` becomes `T` (+ `nullable`)
|
|
15
|
+
* and a multi-type array becomes one `anyOf` branch per type.
|
|
16
|
+
*/
|
|
17
|
+
scalarizeTypeArrays: boolean;
|
|
14
18
|
stripNullableKeyword: boolean;
|
|
15
19
|
autoPropertyOrdering: boolean;
|
|
16
20
|
ensureObjectProperties: boolean;
|
|
@@ -66,8 +70,9 @@ export declare function normalizeSchemaForMCP(value: unknown): unknown;
|
|
|
66
70
|
* rejected; collapse to `enum` with an inferred scalar `type`.
|
|
67
71
|
* - `oneOf` is not an MFJS combinator (only `anyOf` is); residual `oneOf` is
|
|
68
72
|
* folded into `anyOf`.
|
|
69
|
-
* - `type` must be a scalar string; `
|
|
70
|
-
*
|
|
73
|
+
* - `type` must be a scalar string; `["T", "null"]` reduces to `T` (the
|
|
74
|
+
* `null` member is dropped — `nullable` is unsupported) and a multi-type
|
|
75
|
+
* array becomes one typed `anyOf` branch per type.
|
|
71
76
|
* - Enum-bearing nodes get an inferred `type` (the idiomatic MFJS form; a bare
|
|
72
77
|
* `enum` is valid too) so `anyOf` branches always carry a `type`.
|
|
73
78
|
* - Validation/decorative keywords (`minItems`, `maxItems`, `maxLength`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.8.
|
|
3
|
+
"version": "18.8.7",
|
|
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.8.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
158
|
+
"@oh-my-pi/omptype": "18.8.7",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.8.7",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.8.7",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.8.7",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.8.7"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/select.ts
CHANGED
|
@@ -505,9 +505,9 @@ export class CredentialSelector {
|
|
|
505
505
|
* 1. strict: unblocked credentials only, usage limits respected, plan
|
|
506
506
|
* filter enforced (when any account is confirmed eligible);
|
|
507
507
|
* 2. plan-fitting last resort: same plan filter, but blocked/exhausted
|
|
508
|
-
* accounts are allowed (
|
|
509
|
-
* first) so the caller gets real
|
|
510
|
-
* instead of a missing key;
|
|
508
|
+
* accounts are allowed (an explicit session pin first, then blocked
|
|
509
|
+
* candidates earliest-unblocking first) so the caller gets real
|
|
510
|
+
* usage-limit semantics from the wire instead of a missing key;
|
|
511
511
|
* 3. unfiltered last resort: the plan filter matched nothing usable —
|
|
512
512
|
* skip it and try every account once; the server is the final arbiter
|
|
513
513
|
* of model access.
|
|
@@ -535,6 +535,9 @@ export class CredentialSelector {
|
|
|
535
535
|
const credentials = stored.filter(entry => this.#deps.affinity.allows(provider, sessionId, entry.credential));
|
|
536
536
|
|
|
537
537
|
if (credentials.length === 0) return undefined;
|
|
538
|
+
// Ranking and preflight both await provider work; preserve the row each
|
|
539
|
+
// selection started with even if a peer reorders the live pool.
|
|
540
|
+
const credentialIds = this.#deps.pool.entries(provider).map(entry => entry.id);
|
|
538
541
|
this.#deps.policies.validateUsageCapability(provider, this.#deps.usage.canFetchOAuthUsage(provider));
|
|
539
542
|
|
|
540
543
|
const providerKey = providerTypeKey(provider, "oauth");
|
|
@@ -659,6 +662,11 @@ export class CredentialSelector {
|
|
|
659
662
|
? { selection, usage: sessionPreferredUsage, usageChecked: true }
|
|
660
663
|
: { selection, usage: null, usageChecked: false },
|
|
661
664
|
);
|
|
665
|
+
// Keep the candidate object: preflight may rebind its positional index
|
|
666
|
+
// after a peer changes the credential pool.
|
|
667
|
+
const explicitPin = sessionPinIsExplicit
|
|
668
|
+
? candidates.find(candidate => candidate.selection.index === sessionPreferredIndex)
|
|
669
|
+
: undefined;
|
|
662
670
|
const preflightFailures = new Set<OAuthCandidate>();
|
|
663
671
|
// The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
|
|
664
672
|
// When no candidate resolves, it is rethrown so callers retry instead of reporting
|
|
@@ -714,21 +722,24 @@ export class CredentialSelector {
|
|
|
714
722
|
const forceRefreshIndex = options?.forceRefresh
|
|
715
723
|
? (sessionPreferredIndex ?? candidates[0]?.selection.index)
|
|
716
724
|
: undefined;
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// Resyncs after an await (refresh, disable) re-read the store.
|
|
725
|
+
// Candidate IDs were captured before ranking. Their synchronous rebinds
|
|
726
|
+
// share one provider re-list; resyncs after refresh await re-read the store.
|
|
720
727
|
let preflightRows: StoredAuthCredential[] | undefined;
|
|
721
728
|
await Promise.all(
|
|
722
729
|
candidates.map(async candidate => {
|
|
723
730
|
const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
|
|
724
|
-
const initialCredentialId =
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
+
const initialCredentialId = credentialIds[candidate.selection.index];
|
|
732
|
+
if (initialCredentialId === undefined) {
|
|
733
|
+
preflightFailures.add(candidate);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const beforeSync = candidate.selection.credential;
|
|
737
|
+
preflightRows ??= this.#reloadProviderRows(provider);
|
|
738
|
+
if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) {
|
|
739
|
+
preflightFailures.add(candidate);
|
|
740
|
+
return;
|
|
731
741
|
}
|
|
742
|
+
const syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
|
|
732
743
|
const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
|
|
733
744
|
if ((!force || syncedPeerCredential) && hasFreshAccess) return;
|
|
734
745
|
const latestCredential = this.#deps.pool.credentials(provider)[candidate.selection.index];
|
|
@@ -884,9 +895,15 @@ export class CredentialSelector {
|
|
|
884
895
|
];
|
|
885
896
|
if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts });
|
|
886
897
|
if (enforceAccounts) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts: false });
|
|
898
|
+
// Blocked candidates rank earliest-unblock first, which would route a
|
|
899
|
+
// blocked explicit pin to an equally blocked sibling. Once the strict
|
|
900
|
+
// pass finds no unblocked account, the user's pin goes first.
|
|
901
|
+
const lastResortCandidates = explicitPin
|
|
902
|
+
? [explicitPin, ...candidates.filter(candidate => candidate !== explicitPin)]
|
|
903
|
+
: candidates;
|
|
887
904
|
|
|
888
905
|
for (const pass of passes) {
|
|
889
|
-
for (const candidate of candidates) {
|
|
906
|
+
for (const candidate of pass.allowBlocked ? lastResortCandidates : candidates) {
|
|
890
907
|
if (preflightFailures.has(candidate)) continue;
|
|
891
908
|
const candidateAccountKey = oauthAccountKey(candidate.selection.credential);
|
|
892
909
|
if (pass.enforceAccounts && (candidateAccountKey === undefined || !accountIds?.has(candidateAccountKey)))
|
|
@@ -1582,9 +1582,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1582
1582
|
* pick the one whose identity (accountId / email / projectId) lines up with
|
|
1583
1583
|
* the credential the caller is asking about.
|
|
1584
1584
|
*
|
|
1585
|
-
* Falls back to the lone
|
|
1586
|
-
* through to `null` when nothing matches, which
|
|
1587
|
-
* usage data" (ranking proceeds without a usage
|
|
1585
|
+
* Falls back to the provider's lone report when it shares no identity field
|
|
1586
|
+
* with the credential; falls through to `null` when nothing matches, which
|
|
1587
|
+
* `AuthStorage` treats as "no usage data" (ranking proceeds without a usage
|
|
1588
|
+
* signal for this credential).
|
|
1588
1589
|
*/
|
|
1589
1590
|
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
|
|
1590
1591
|
const all = reports.filter(report => report.provider === provider);
|
|
@@ -1635,7 +1636,17 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
|
|
|
1635
1636
|
report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1636
1637
|
);
|
|
1637
1638
|
if (candidates.length === 0) return null;
|
|
1638
|
-
|
|
1639
|
+
// The sole report stands in for a credential it shares no identity field
|
|
1640
|
+
// with; otherwise identity decides, because a report naming another email,
|
|
1641
|
+
// account or project is a sibling's pool whose fetch succeeded where this
|
|
1642
|
+
// credential's failed.
|
|
1643
|
+
if (
|
|
1644
|
+
all.length === 1 &&
|
|
1645
|
+
candidates.length === 1 &&
|
|
1646
|
+
!reportHasComparableIdentity(candidates[0], accountId, email, projectId)
|
|
1647
|
+
) {
|
|
1648
|
+
return candidates[0];
|
|
1649
|
+
}
|
|
1639
1650
|
for (const report of candidates) {
|
|
1640
1651
|
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
|
|
1641
1652
|
}
|
|
@@ -1699,7 +1710,13 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
|
|
|
1699
1710
|
candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId"),
|
|
1700
1711
|
);
|
|
1701
1712
|
if (candidates.length === 0) return -1;
|
|
1702
|
-
if (
|
|
1713
|
+
if (
|
|
1714
|
+
all.length === 1 &&
|
|
1715
|
+
candidates.length === 1 &&
|
|
1716
|
+
!reportHasComparableIdentity(candidates[0]!.report, accountId, email, projectId)
|
|
1717
|
+
) {
|
|
1718
|
+
return candidates[0]!.index;
|
|
1719
|
+
}
|
|
1703
1720
|
for (const candidate of candidates) {
|
|
1704
1721
|
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
|
|
1705
1722
|
}
|
|
@@ -1721,19 +1738,39 @@ function reportMatchesIdentity(
|
|
|
1721
1738
|
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
|
|
1722
1739
|
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
|
|
1723
1740
|
for (const limit of report.limits) {
|
|
1724
|
-
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
|
|
1741
|
+
if (limit.scope.accountId?.trim().toLowerCase() === accountId) return true;
|
|
1725
1742
|
}
|
|
1726
1743
|
}
|
|
1727
1744
|
if (projectId) {
|
|
1728
1745
|
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
|
|
1729
1746
|
if (metaProject && metaProject.toLowerCase() === projectId) return true;
|
|
1730
1747
|
for (const limit of report.limits) {
|
|
1731
|
-
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
|
|
1748
|
+
if (limit.scope.projectId?.trim().toLowerCase() === projectId) return true;
|
|
1732
1749
|
}
|
|
1733
1750
|
}
|
|
1734
1751
|
return false;
|
|
1735
1752
|
}
|
|
1736
1753
|
|
|
1754
|
+
/** Whether the report names an email, account or project the caller can compare against. */
|
|
1755
|
+
function reportHasComparableIdentity(
|
|
1756
|
+
report: UsageReport,
|
|
1757
|
+
accountId: string | undefined,
|
|
1758
|
+
email: string | undefined,
|
|
1759
|
+
projectId: string | undefined,
|
|
1760
|
+
): boolean {
|
|
1761
|
+
const metadata = report.metadata ?? {};
|
|
1762
|
+
if (email && readMetadataString(metadata, "email")) return true;
|
|
1763
|
+
if (accountId) {
|
|
1764
|
+
if (readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id")) return true;
|
|
1765
|
+
if (report.limits.some(limit => limit.scope.accountId?.trim())) return true;
|
|
1766
|
+
}
|
|
1767
|
+
if (projectId) {
|
|
1768
|
+
if (readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id")) return true;
|
|
1769
|
+
if (report.limits.some(limit => limit.scope.projectId?.trim())) return true;
|
|
1770
|
+
}
|
|
1771
|
+
return false;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1737
1774
|
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
|
|
1738
1775
|
const value = metadata[key];
|
|
1739
1776
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -78,15 +78,17 @@ const ACCOUNT_SCOPED_403_PATTERN =
|
|
|
78
78
|
/\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i;
|
|
79
79
|
// Simplified Chinese account-quota exhaustion phrasing. Zhipu Coding Plan
|
|
80
80
|
// returns e.g. "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。"
|
|
81
|
-
// (type=1308) when the 5h window is spent;
|
|
81
|
+
// (type=1308) when the 5h window is spent; MiniMax CN returns
|
|
82
|
+
// "当前已达到 Token Plan 用量上限。…" (2067); other CN providers use 额度已用完 /
|
|
82
83
|
// 配额已耗尽 / 余额不足. These are persistent account-local caps that must
|
|
83
84
|
// rotate to a sibling credential, not transient rate limits, so they are
|
|
84
85
|
// matched before the RATE_LIMIT_EXCEEDED branch. The 上限 arm is anchored on
|
|
85
|
-
// the 使用
|
|
86
|
-
// 并发请求数已达上限 / 速率达到上限 (
|
|
87
|
-
// healthy sibling credential as a false quota. "速率限制" is absent for
|
|
88
|
-
// same reason.
|
|
89
|
-
const CN_QUOTA_EXHAUSTED_PATTERN =
|
|
86
|
+
// the 使用 / 用量 tokens: a rate/concurrency cap phrased as 每分钟请求数已达上限 /
|
|
87
|
+
// 并发请求数已达上限 / 速率达到上限 (neither token) must NOT match, or it would
|
|
88
|
+
// burn a healthy sibling credential as a false quota. "速率限制" is absent for
|
|
89
|
+
// the same reason.
|
|
90
|
+
const CN_QUOTA_EXHAUSTED_PATTERN =
|
|
91
|
+
/(?:使用|用量).{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/;
|
|
90
92
|
// Simplified Chinese rate/concurrency caps can contain both 使用 and 上限, but
|
|
91
93
|
// remain transient rather than account quota exhaustion.
|
|
92
94
|
const CN_TRANSIENT_CAP_PATTERN =
|
|
@@ -450,16 +450,20 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
450
450
|
let toolConfig = toolPlan.toolConfig;
|
|
451
451
|
const sentinelInjected = toolPlan.sentinelInjected;
|
|
452
452
|
let additionalModelRequestFields = buildAdditionalModelRequestFields(model, options);
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
const thinkingDisabled =
|
|
454
|
+
isRecord(additionalModelRequestFields?.thinking) &&
|
|
455
|
+
additionalModelRequestFields.thinking.type === "disabled";
|
|
456
|
+
const prefixMismatchBehavior =
|
|
457
|
+
!thinkingDisabled && model.thinking?.prefixBinding
|
|
458
|
+
? (options.anthropicPrefixMismatchBehavior ?? "drop_block")
|
|
459
|
+
: undefined;
|
|
456
460
|
|
|
457
461
|
// Some models (Opus/Sonnet 5.5) reject forced tool use outright; keep the
|
|
458
462
|
// tools offered under `auto` and leave thinking intact.
|
|
459
463
|
const forcedChoice = toolConfig?.toolChoice?.any || toolConfig?.toolChoice?.tool;
|
|
460
464
|
if (toolConfig && forcedChoice && !model.compat.supportsForcedToolChoice) {
|
|
461
465
|
toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
|
|
462
|
-
} else if (toolConfig && forcedChoice && additionalModelRequestFields) {
|
|
466
|
+
} else if (toolConfig && forcedChoice && additionalModelRequestFields && !thinkingDisabled) {
|
|
463
467
|
// Bedrock rejects thinking + forced tool_choice. Fable's adaptive
|
|
464
468
|
// thinking cannot be disabled, so downgrade its forced choice instead.
|
|
465
469
|
if (prefixMismatchBehavior) toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
|
|
@@ -1335,7 +1339,13 @@ function buildAdditionalModelRequestFields(
|
|
|
1335
1339
|
options: BedrockOptions,
|
|
1336
1340
|
): Record<string, unknown> | undefined {
|
|
1337
1341
|
const reasoning = options.reasoning;
|
|
1338
|
-
if (!
|
|
1342
|
+
if (!model.reasoning) return undefined;
|
|
1343
|
+
if (!reasoning) {
|
|
1344
|
+
if (model.compat.disabledThinking === "disabled") {
|
|
1345
|
+
return { thinking: { type: "disabled" }, output_config: { effort: "low" } };
|
|
1346
|
+
}
|
|
1347
|
+
return undefined;
|
|
1348
|
+
}
|
|
1339
1349
|
|
|
1340
1350
|
const mode = model.thinking?.mode;
|
|
1341
1351
|
if (mode === "anthropic-adaptive") {
|
|
@@ -4514,6 +4514,23 @@ export function resolveAnthropicCompactionEffort(
|
|
|
4514
4514
|
);
|
|
4515
4515
|
}
|
|
4516
4516
|
|
|
4517
|
+
/**
|
|
4518
|
+
* Whether effort controls earlier requests left in `messages` rule out
|
|
4519
|
+
* `thinking: {type: "disabled"}`: Anthropic rejects a per-message effort
|
|
4520
|
+
* control with thinking off, and `disabled` above `high` effort. Both stay in
|
|
4521
|
+
* force across a thinking toggle, so such a request falls back to the
|
|
4522
|
+
* adaptive-only off path (lowest effort) instead of a 400.
|
|
4523
|
+
*/
|
|
4524
|
+
function effortControlsBlockDisabledThinking(
|
|
4525
|
+
model: Model<"anthropic-messages">,
|
|
4526
|
+
messages: readonly Message[],
|
|
4527
|
+
records: readonly AnthropicControlRecord[],
|
|
4528
|
+
): boolean {
|
|
4529
|
+
if (model.compat.supportsPerMessageEffort !== true) return false;
|
|
4530
|
+
const plan = planAnthropicEffortControls(undefined, messages, records, true, undefined, false);
|
|
4531
|
+
return plan.inserts.length > 0 || plan.topLevel === "xhigh" || plan.topLevel === "max";
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4517
4534
|
/**
|
|
4518
4535
|
* Splice control markers into `messages`. Inserts sharing an index come from
|
|
4519
4536
|
* one request and merge into one control: tool changes first, then the effort.
|
|
@@ -4732,7 +4749,11 @@ function buildParams(
|
|
|
4732
4749
|
let outputConfigEffort: AnthropicOutputEffort | undefined;
|
|
4733
4750
|
if (model.reasoning) {
|
|
4734
4751
|
const disabledThinking = model.compat.disabledThinking;
|
|
4735
|
-
if (
|
|
4752
|
+
if (
|
|
4753
|
+
options?.thinkingEnabled === false &&
|
|
4754
|
+
disabledThinking !== undefined &&
|
|
4755
|
+
!(disabledThinking === "disabled" && effortControlsBlockDisabledThinking(model, context.messages, records))
|
|
4756
|
+
) {
|
|
4736
4757
|
if (disabledThinking !== "omit") {
|
|
4737
4758
|
thinking =
|
|
4738
4759
|
disabledThinking === "adaptive"
|
|
@@ -102,6 +102,7 @@ export { setCodexAttestationProvider } from "./openai-codex-attestation";
|
|
|
102
102
|
export type { CodexAttestationProvider } from "./openai-codex-attestation";
|
|
103
103
|
import {
|
|
104
104
|
getOpenAIEffortControlState,
|
|
105
|
+
releaseOpenAIEffortControlSession,
|
|
105
106
|
type OpenAIEffortControlState,
|
|
106
107
|
planStableOpenAIEffort,
|
|
107
108
|
} from "./openai-configuration-update";
|
|
@@ -434,6 +435,7 @@ export interface OpenAICodexWebSocketDebugStats {
|
|
|
434
435
|
* is historical — SSE-only sessions use it too.
|
|
435
436
|
*/
|
|
436
437
|
type CodexWebSocketSessionState = {
|
|
438
|
+
sessionId: string;
|
|
437
439
|
disableWebsocket: boolean;
|
|
438
440
|
lastRequest?: RequestBody;
|
|
439
441
|
/** Last completed response; an in-progress response cannot replace the retry baseline. */
|
|
@@ -1058,6 +1060,20 @@ function createCodexProviderSessionState(): CodexProviderSessionState {
|
|
|
1058
1060
|
webSocketPublicToPrivate: new Map(),
|
|
1059
1061
|
metadataSessions: new Map(),
|
|
1060
1062
|
effortControls: new Map(),
|
|
1063
|
+
releaseSession: sessionId => {
|
|
1064
|
+
const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
|
|
1065
|
+
if (!normalizedSessionId) return;
|
|
1066
|
+
for (const [key, session] of state.webSocketSessions) {
|
|
1067
|
+
if (session.sessionId !== normalizedSessionId) continue;
|
|
1068
|
+
session.connection?.close("session_disposed");
|
|
1069
|
+
state.webSocketSessions.delete(key);
|
|
1070
|
+
for (const [publicKey, privateKey] of state.webSocketPublicToPrivate) {
|
|
1071
|
+
if (privateKey === key) state.webSocketPublicToPrivate.delete(publicKey);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
state.metadataSessions.delete(normalizedSessionId);
|
|
1075
|
+
releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
|
|
1076
|
+
},
|
|
1061
1077
|
close: () => {
|
|
1062
1078
|
for (const session of state.webSocketSessions.values()) {
|
|
1063
1079
|
session.connection?.close("session_disposed");
|
|
@@ -1472,14 +1488,14 @@ function createCodexRequestContext(
|
|
|
1472
1488
|
transportProviderSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
1473
1489
|
}
|
|
1474
1490
|
const sharedWebsocketState =
|
|
1475
|
-
sessionKey && providerSessionState
|
|
1491
|
+
sessionKey && transportSessionId && providerSessionState
|
|
1476
1492
|
? isolatedTransportState
|
|
1477
1493
|
? providerSessionState.webSocketSessions.get(sessionKey)
|
|
1478
|
-
: getCodexWebSocketSessionState(sessionKey, providerSessionState)
|
|
1494
|
+
: getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState)
|
|
1479
1495
|
: undefined;
|
|
1480
1496
|
const websocketState =
|
|
1481
|
-
sessionKey && isolatedTransportState
|
|
1482
|
-
? getCodexWebSocketSessionState(sessionKey, isolatedTransportState)
|
|
1497
|
+
sessionKey && transportSessionId && isolatedTransportState
|
|
1498
|
+
? getCodexWebSocketSessionState(sessionKey, transportSessionId, isolatedTransportState)
|
|
1483
1499
|
: sharedWebsocketState;
|
|
1484
1500
|
if (isolatedTransportState && websocketState && sharedWebsocketState) {
|
|
1485
1501
|
websocketState.disableWebsocket = sharedWebsocketState.disableWebsocket;
|
|
@@ -1616,7 +1632,7 @@ function applyCodexStableEffort(
|
|
|
1616
1632
|
const providerState = getCodexProviderSessionState(options?.providerSessionState);
|
|
1617
1633
|
const sessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
|
1618
1634
|
if (!providerState || !sessionId) return;
|
|
1619
|
-
const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}
|
|
1635
|
+
const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}`, sessionId);
|
|
1620
1636
|
body.reasoning = { ...body.reasoning, effort: planStableOpenAIEffort(state, body.input, effort) };
|
|
1621
1637
|
}
|
|
1622
1638
|
|
|
@@ -3361,13 +3377,10 @@ export async function prewarmOpenAICodexResponses(
|
|
|
3361
3377
|
if (publicSessionKey && sessionKey) {
|
|
3362
3378
|
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
3363
3379
|
}
|
|
3364
|
-
if (!sessionKey || !providerSessionState) return;
|
|
3365
|
-
const state = getCodexWebSocketSessionState(sessionKey, providerSessionState);
|
|
3380
|
+
if (!sessionKey || !transportSessionId || !providerSessionState) return;
|
|
3381
|
+
const state = getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState);
|
|
3366
3382
|
if (!shouldUseCodexWebSocket(model, state, options?.preferWebsockets)) return;
|
|
3367
|
-
const metadataSession = getOrCreateCodexMetadataSessionState(
|
|
3368
|
-
transportSessionId ?? crypto.randomUUID(),
|
|
3369
|
-
providerSessionState,
|
|
3370
|
-
);
|
|
3383
|
+
const metadataSession = getOrCreateCodexMetadataSessionState(transportSessionId, providerSessionState);
|
|
3371
3384
|
const turnState = getOrCreateCodexTurnState(metadataSession, sessionKey);
|
|
3372
3385
|
const codexClientVersion = CODEX_CLIENT_VERSION;
|
|
3373
3386
|
const requestIdentity = createCodexCompatibilityIdentity(metadataSession);
|
|
@@ -3418,11 +3431,13 @@ function getCodexWebSocketSessionKey(
|
|
|
3418
3431
|
|
|
3419
3432
|
function getCodexWebSocketSessionState(
|
|
3420
3433
|
sessionKey: string,
|
|
3434
|
+
sessionId: string,
|
|
3421
3435
|
providerSessionState: CodexProviderSessionState,
|
|
3422
3436
|
): CodexWebSocketSessionState {
|
|
3423
3437
|
const existing = providerSessionState.webSocketSessions.get(sessionKey);
|
|
3424
3438
|
if (existing) return existing;
|
|
3425
3439
|
const created: CodexWebSocketSessionState = {
|
|
3440
|
+
sessionId,
|
|
3426
3441
|
disableWebsocket: false,
|
|
3427
3442
|
canAppend: false,
|
|
3428
3443
|
fallbackCount: 0,
|
|
@@ -36,13 +36,16 @@ interface EffortTransition<TEffort extends string> {
|
|
|
36
36
|
|
|
37
37
|
/** Per-conversation effort baseline and recorded transitions. */
|
|
38
38
|
export interface OpenAIEffortControlState<TEffort extends string = string> {
|
|
39
|
+
sessionId: string;
|
|
39
40
|
baseEffort?: TEffort;
|
|
40
41
|
currentEffort?: TEffort;
|
|
41
42
|
transitions: EffortTransition<TEffort>[];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export function createOpenAIEffortControlState<TEffort extends string>(
|
|
45
|
-
|
|
45
|
+
export function createOpenAIEffortControlState<TEffort extends string>(
|
|
46
|
+
sessionId: string,
|
|
47
|
+
): OpenAIEffortControlState<TEffort> {
|
|
48
|
+
return { sessionId, transitions: [] };
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const MAX_EFFORT_CONTROL_STATES = 16;
|
|
@@ -54,6 +57,7 @@ const MAX_EFFORT_CONTROL_STATES = 16;
|
|
|
54
57
|
export function getOpenAIEffortControlState<TEffort extends string>(
|
|
55
58
|
states: Map<string, OpenAIEffortControlState<TEffort>>,
|
|
56
59
|
key: string,
|
|
60
|
+
sessionId: string,
|
|
57
61
|
): OpenAIEffortControlState<TEffort> {
|
|
58
62
|
const existing = states.get(key);
|
|
59
63
|
if (existing) {
|
|
@@ -61,7 +65,7 @@ export function getOpenAIEffortControlState<TEffort extends string>(
|
|
|
61
65
|
states.set(key, existing);
|
|
62
66
|
return existing;
|
|
63
67
|
}
|
|
64
|
-
const created = createOpenAIEffortControlState<TEffort>();
|
|
68
|
+
const created = createOpenAIEffortControlState<TEffort>(sessionId);
|
|
65
69
|
states.set(key, created);
|
|
66
70
|
if (states.size > MAX_EFFORT_CONTROL_STATES) {
|
|
67
71
|
const oldest = states.keys().next().value;
|
|
@@ -70,6 +74,16 @@ export function getOpenAIEffortControlState<TEffort extends string>(
|
|
|
70
74
|
return created;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
|
|
78
|
+
export function releaseOpenAIEffortControlSession<TEffort extends string>(
|
|
79
|
+
states: Map<string, OpenAIEffortControlState<TEffort>>,
|
|
80
|
+
sessionId: string,
|
|
81
|
+
): void {
|
|
82
|
+
for (const [key, state] of states) {
|
|
83
|
+
if (state.sessionId === sessionId) states.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
interface AnchorableItem {
|
|
74
88
|
type?: string | null;
|
|
75
89
|
role?: string;
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
import { compactGrammarDefinition } from "./grammar";
|
|
57
57
|
import {
|
|
58
58
|
getOpenAIEffortControlState,
|
|
59
|
+
releaseOpenAIEffortControlSession,
|
|
59
60
|
type OpenAIEffortControlState,
|
|
60
61
|
planStableOpenAIEffort,
|
|
61
62
|
} from "./openai-configuration-update";
|
|
@@ -95,6 +96,7 @@ import {
|
|
|
95
96
|
getJuiceValue,
|
|
96
97
|
getOpenAIPromptCacheKey,
|
|
97
98
|
getOpenAIResponsesRoutingSessionId,
|
|
99
|
+
normalizeOpenAIPromptCacheKey,
|
|
98
100
|
getOpenAIStrictToolsScope,
|
|
99
101
|
getOpenRouterResponsesSessionId,
|
|
100
102
|
isCompiledGrammarTooLargeStrictError,
|
|
@@ -404,6 +406,7 @@ interface OpenAIResponsesProviderSessionState
|
|
|
404
406
|
type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
|
|
405
407
|
|
|
406
408
|
interface OpenAIResponsesChainState {
|
|
409
|
+
sessionId: string;
|
|
407
410
|
/**
|
|
408
411
|
* Wire params of the last successful turn; never carries
|
|
409
412
|
* `previous_response_id`.
|
|
@@ -429,6 +432,14 @@ function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSes
|
|
|
429
432
|
nativeHistoryReplayWarmed: false,
|
|
430
433
|
chains: new Map(),
|
|
431
434
|
effortControls: new Map(),
|
|
435
|
+
releaseSession: sessionId => {
|
|
436
|
+
const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
|
|
437
|
+
if (!normalizedSessionId) return;
|
|
438
|
+
for (const [key, chain] of state.chains) {
|
|
439
|
+
if (chain.sessionId === normalizedSessionId) state.chains.delete(key);
|
|
440
|
+
}
|
|
441
|
+
releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
|
|
442
|
+
},
|
|
432
443
|
close: () => {
|
|
433
444
|
state.nativeHistoryReplayWarmed = false;
|
|
434
445
|
state.chains.clear();
|
|
@@ -511,7 +522,7 @@ function getOpenAIResponsesChainState(
|
|
|
511
522
|
const key = `${resolvedBaseUrl ?? model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`;
|
|
512
523
|
const existing = providerSessionState.chains.get(key);
|
|
513
524
|
if (existing) return existing;
|
|
514
|
-
const created: OpenAIResponsesChainState = { canAppend: false, staleFailures: 0, disabled: false };
|
|
525
|
+
const created: OpenAIResponsesChainState = { sessionId, canAppend: false, staleFailures: 0, disabled: false };
|
|
515
526
|
providerSessionState.chains.set(key, created);
|
|
516
527
|
return created;
|
|
517
528
|
}
|
|
@@ -1744,6 +1755,7 @@ function applyResponsesStableEffort(
|
|
|
1744
1755
|
const state = getOpenAIEffortControlState(
|
|
1745
1756
|
providerSessionState.effortControls,
|
|
1746
1757
|
`${model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`,
|
|
1758
|
+
sessionId,
|
|
1747
1759
|
);
|
|
1748
1760
|
params.reasoning = { ...reasoning, effort: planStableOpenAIEffort(state, input, effort) };
|
|
1749
1761
|
}
|
package/src/types.ts
CHANGED
|
@@ -405,6 +405,8 @@ export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily |
|
|
|
405
405
|
|
|
406
406
|
export interface ProviderSessionState {
|
|
407
407
|
close(): void;
|
|
408
|
+
/** Release one routing session after its final request, preserving shared provider fallbacks. */
|
|
409
|
+
releaseSession?(sessionId: string): void;
|
|
408
410
|
}
|
|
409
411
|
|
|
410
412
|
export interface ProviderResponseMetadata {
|
package/src/utils/glyph-codec.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
AssistantMessageEvent,
|
|
4
|
+
Context,
|
|
5
|
+
CursorExecHandlers,
|
|
6
|
+
ImageContent,
|
|
7
|
+
Message,
|
|
8
|
+
TextContent,
|
|
9
|
+
ToolCall,
|
|
10
|
+
} from "../types";
|
|
2
11
|
import { getStreamingPartialJson, setStreamingPartialJson } from "./block-symbols";
|
|
3
12
|
import { AssistantMessageEventStream } from "./event-stream";
|
|
4
13
|
import glyphNotice from "./glyph-notice.md" with { type: "text" };
|
|
@@ -10,6 +19,9 @@ const GLYPH_ENCODE_PATTERN =
|
|
|
10
19
|
/([\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}])|⟦(?=(?:U[0-9a-fA-F]{4,6}|E)⟧)/gu;
|
|
11
20
|
const GLYPH_DECODE_PATTERN = /⟦E⟧|⟦U([0-9a-fA-F]{4,6})⟧/gu;
|
|
12
21
|
|
|
22
|
+
/** A trailing token or escape prefix that a later delta may still complete; sticky, tested at the last opener. */
|
|
23
|
+
const GLYPH_PENDING_TAIL = /⟦(?:U[0-9a-fA-F]{0,6}|E)?$/uy;
|
|
24
|
+
|
|
13
25
|
const kGlyphEncoded = Symbol("provider.context.glyphEncoded");
|
|
14
26
|
|
|
15
27
|
interface GlyphEncodedContext extends Context {
|
|
@@ -20,7 +32,7 @@ interface GlyphEncodedContext extends Context {
|
|
|
20
32
|
export interface GlyphCodec {
|
|
21
33
|
/** Context with model-bound glyphs encoded and one conditional convention notice. */
|
|
22
34
|
context: Context;
|
|
23
|
-
/** Decodes glyph tokens
|
|
35
|
+
/** Decodes glyph tokens live: text and tool-call deltas, partial snapshots, completed tool calls, and the terminal message. */
|
|
24
36
|
wrap(inner: AssistantMessageEventStream): AssistantMessageEventStream;
|
|
25
37
|
/** Decodes Cursor Pi calls and encodes their provider-bound results. */
|
|
26
38
|
wrapCursorExecHandlers(handlers: CursorExecHandlers): CursorExecHandlers;
|
|
@@ -287,15 +299,196 @@ function decodeAssistantMessageInPlace(message: AssistantMessage): void {
|
|
|
287
299
|
}
|
|
288
300
|
}
|
|
289
301
|
|
|
302
|
+
/** Decoded copy of a tool call, or the block itself when it carries no tokens. */
|
|
303
|
+
function decodeToolCall(block: ToolCall): ToolCall {
|
|
304
|
+
const args = transformRecord(block.arguments, decodeGlyphText);
|
|
305
|
+
const partialJson = getStreamingPartialJson(block);
|
|
306
|
+
const json = partialJson === undefined ? undefined : decodeGlyphText(partialJson);
|
|
307
|
+
if (args === block.arguments && json === partialJson) return block;
|
|
308
|
+
const decoded: ToolCall = { ...block, arguments: args };
|
|
309
|
+
if (json !== undefined) setStreamingPartialJson(decoded, json);
|
|
310
|
+
return decoded;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Decodes one streamed block incrementally, holding back a token split across deltas. */
|
|
314
|
+
class GlyphDeltaDecoder {
|
|
315
|
+
#pending = "";
|
|
316
|
+
|
|
317
|
+
/** Decodes everything in the held-back tail plus `delta` that no later delta can still change. */
|
|
318
|
+
push(delta: string): string {
|
|
319
|
+
const text = this.#pending + delta;
|
|
320
|
+
const open = text.lastIndexOf(GLYPH_OPEN);
|
|
321
|
+
let cut = text.length;
|
|
322
|
+
if (open >= 0) {
|
|
323
|
+
GLYPH_PENDING_TAIL.lastIndex = open;
|
|
324
|
+
if (GLYPH_PENDING_TAIL.test(text)) cut = open;
|
|
325
|
+
}
|
|
326
|
+
this.#pending = text.slice(cut);
|
|
327
|
+
return decodeGlyphText(cut === text.length ? text : text.slice(0, cut));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Releases the held-back tail verbatim: an unfinished token is literal text. */
|
|
331
|
+
flush(): string {
|
|
332
|
+
const tail = this.#pending;
|
|
333
|
+
this.#pending = "";
|
|
334
|
+
return tail;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Decode state for one streamed text or tool-call block. */
|
|
339
|
+
interface GlyphChannel {
|
|
340
|
+
kind: "text" | "toolCall";
|
|
341
|
+
decoder: GlyphDeltaDecoder;
|
|
342
|
+
/** Decoded text forwarded so far; text blocks only. */
|
|
343
|
+
text: string;
|
|
344
|
+
/** Whether a delta carried a token opener; tool-call stand-ins are only rebuilt then. */
|
|
345
|
+
tokens: boolean;
|
|
346
|
+
/** Decoded stand-in for the provider's block in forwarded partials. */
|
|
347
|
+
view?: AssistantMessage["content"][number];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Re-projects a provider stream with glyph tokens decoded as they arrive, so
|
|
352
|
+
* delta consumers (side-channel replies, argument streams) and partial
|
|
353
|
+
* snapshots (live rendering, speculation) never see wire tokens.
|
|
354
|
+
*
|
|
355
|
+
* Provider-owned blocks are never mutated mid-stream — the provider keeps
|
|
356
|
+
* appending to them — so decoded stand-ins are swapped into each forwarded
|
|
357
|
+
* partial instead. The terminal message is decoded in place once. Thinking
|
|
358
|
+
* stays encoded, matching {@link decodeAssistantMessageInPlace}.
|
|
359
|
+
*/
|
|
360
|
+
class GlyphStreamProjector {
|
|
361
|
+
readonly #out: AssistantMessageEventStream;
|
|
362
|
+
readonly #channels = new Map<number, GlyphChannel>();
|
|
363
|
+
/** Set once any block has a stand-in; until then events pass through untouched. */
|
|
364
|
+
#projected = false;
|
|
365
|
+
|
|
366
|
+
constructor(out: AssistantMessageEventStream) {
|
|
367
|
+
this.#out = out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
push(event: AssistantMessageEvent): void {
|
|
371
|
+
switch (event.type) {
|
|
372
|
+
case "start":
|
|
373
|
+
this.#channels.clear();
|
|
374
|
+
this.#projected = false;
|
|
375
|
+
this.#out.push(event);
|
|
376
|
+
return;
|
|
377
|
+
case "text_delta": {
|
|
378
|
+
const channel = this.#channel(event.contentIndex, "text");
|
|
379
|
+
const delta = channel.decoder.push(event.delta);
|
|
380
|
+
channel.text += delta;
|
|
381
|
+
const block = event.partial.content[event.contentIndex];
|
|
382
|
+
if (block?.type === "text" && (channel.view !== undefined || delta !== event.delta)) {
|
|
383
|
+
this.#setView(channel, { ...block, text: channel.text });
|
|
384
|
+
}
|
|
385
|
+
if (!delta) return;
|
|
386
|
+
this.#out.push(
|
|
387
|
+
delta === event.delta && !this.#projected
|
|
388
|
+
? event
|
|
389
|
+
: { ...event, delta, partial: this.#partial(event.partial) },
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "text_end": {
|
|
394
|
+
const channel = this.#channel(event.contentIndex, "text");
|
|
395
|
+
this.#flush(event.contentIndex, channel, event.partial);
|
|
396
|
+
const content = decodeGlyphText(event.content);
|
|
397
|
+
const block = event.partial.content[event.contentIndex];
|
|
398
|
+
if (block?.type === "text" && (channel.view !== undefined || content !== event.content)) {
|
|
399
|
+
this.#setView(channel, { ...block, text: content });
|
|
400
|
+
}
|
|
401
|
+
this.#out.push(
|
|
402
|
+
content === event.content && !this.#projected
|
|
403
|
+
? event
|
|
404
|
+
: { ...event, content, partial: this.#partial(event.partial) },
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
case "toolcall_delta": {
|
|
409
|
+
const channel = this.#channel(event.contentIndex, "toolCall");
|
|
410
|
+
const delta = channel.decoder.push(event.delta);
|
|
411
|
+
channel.tokens ||= event.delta.includes(GLYPH_OPEN);
|
|
412
|
+
const block = event.partial.content[event.contentIndex];
|
|
413
|
+
if (channel.tokens && block?.type === "toolCall") {
|
|
414
|
+
const view = decodeToolCall(block);
|
|
415
|
+
if (view === block) channel.view = undefined;
|
|
416
|
+
else this.#setView(channel, view);
|
|
417
|
+
}
|
|
418
|
+
if (!delta) return;
|
|
419
|
+
this.#out.push(
|
|
420
|
+
delta === event.delta && !this.#projected
|
|
421
|
+
? event
|
|
422
|
+
: { ...event, delta, partial: this.#partial(event.partial) },
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case "toolcall_end": {
|
|
427
|
+
const channel = this.#channel(event.contentIndex, "toolCall");
|
|
428
|
+
this.#flush(event.contentIndex, channel, event.partial);
|
|
429
|
+
const toolCall = decodeToolCall(event.toolCall);
|
|
430
|
+
if (toolCall === event.toolCall) channel.view = undefined;
|
|
431
|
+
else this.#setView(channel, toolCall);
|
|
432
|
+
this.#out.push(
|
|
433
|
+
toolCall === event.toolCall && !this.#projected
|
|
434
|
+
? event
|
|
435
|
+
: { ...event, toolCall, partial: this.#partial(event.partial) },
|
|
436
|
+
);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
case "done":
|
|
440
|
+
case "error": {
|
|
441
|
+
const message = event.type === "done" ? event.message : event.error;
|
|
442
|
+
for (const [index, channel] of this.#channels) this.#flush(index, channel, message);
|
|
443
|
+
decodeAssistantMessageInPlace(message);
|
|
444
|
+
this.#out.push(event);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
default:
|
|
448
|
+
this.#out.push(this.#projected ? { ...event, partial: this.#partial(event.partial) } : event);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#channel(index: number, kind: GlyphChannel["kind"]): GlyphChannel {
|
|
453
|
+
let channel = this.#channels.get(index);
|
|
454
|
+
if (channel === undefined) {
|
|
455
|
+
channel = { kind, decoder: new GlyphDeltaDecoder(), text: "", tokens: false };
|
|
456
|
+
this.#channels.set(index, channel);
|
|
457
|
+
}
|
|
458
|
+
return channel;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#setView(channel: GlyphChannel, view: AssistantMessage["content"][number]): void {
|
|
462
|
+
channel.view = view;
|
|
463
|
+
this.#projected = true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Forwards a block's held-back tail before the block ends or the stream settles. */
|
|
467
|
+
#flush(index: number, channel: GlyphChannel, partial: AssistantMessage): void {
|
|
468
|
+
const tail = channel.decoder.flush();
|
|
469
|
+
if (!tail) return;
|
|
470
|
+
if (channel.kind === "toolCall") {
|
|
471
|
+
this.#out.push({ type: "toolcall_delta", contentIndex: index, delta: tail, partial: this.#partial(partial) });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
channel.text += tail;
|
|
475
|
+
const block = partial.content[index];
|
|
476
|
+
if (block?.type === "text") this.#setView(channel, { ...block, text: channel.text });
|
|
477
|
+
this.#out.push({ type: "text_delta", contentIndex: index, delta: tail, partial: this.#partial(partial) });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#partial(partial: AssistantMessage): AssistantMessage {
|
|
481
|
+
if (!this.#projected) return partial;
|
|
482
|
+
return { ...partial, content: partial.content.map((block, index) => this.#channels.get(index)?.view ?? block) };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
290
486
|
function wrapGlyphStream(inner: AssistantMessageEventStream): AssistantMessageEventStream {
|
|
291
487
|
const out = new AssistantMessageEventStream();
|
|
488
|
+
const projector = new GlyphStreamProjector(out);
|
|
292
489
|
void (async () => {
|
|
293
490
|
try {
|
|
294
|
-
for await (const event of inner)
|
|
295
|
-
if (event.type === "done") decodeAssistantMessageInPlace(event.message);
|
|
296
|
-
else if (event.type === "error") decodeAssistantMessageInPlace(event.error);
|
|
297
|
-
out.push(event);
|
|
298
|
-
}
|
|
491
|
+
for await (const event of inner) projector.push(event);
|
|
299
492
|
if (!out.done) {
|
|
300
493
|
const result = await inner.result();
|
|
301
494
|
decodeAssistantMessageInPlace(result);
|
|
@@ -64,19 +64,21 @@ Schemas sent on the Google JSON Schema path MUST follow:
|
|
|
64
64
|
|
|
65
65
|
1. **Unsupported JSON Schema keywords are stripped (except property names under `properties`)**
|
|
66
66
|
- Unsupported keys (`UNSUPPORTED_SCHEMA_FIELDS`):
|
|
67
|
-
- `$schema`, `$ref`, `$defs`, `$dynamicRef`, `$dynamicAnchor`
|
|
67
|
+
- `$schema`, `$id`, `$anchor`, `$ref`, `$defs`, `$dynamicRef`, `$dynamicAnchor`
|
|
68
68
|
- `examples`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`
|
|
69
|
-
- `patternProperties`, `additionalProperties`
|
|
70
|
-
- `minItems`, `maxItems`, `
|
|
71
|
-
- `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`
|
|
72
|
-
- `pattern`, `format`
|
|
69
|
+
- `patternProperties`, `additionalProperties`, `propertyNames`
|
|
70
|
+
- `minItems`, `maxItems`, `uniqueItems`, `contains`, `minContains`, `maxContains`, `additionalItems`
|
|
71
|
+
- `minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
|
|
72
|
+
- `pattern`, `format`, `contentEncoding`, `contentMediaType`, `contentSchema`
|
|
73
73
|
- `dependencies`, `dependentSchemas`, `dependentRequired`
|
|
74
|
-
- `deprecated`, `readOnly`, `writeOnly`, `$comment`
|
|
74
|
+
- `x-mcp-header`, `deprecated`, `readOnly`, `writeOnly`, `$comment`
|
|
75
75
|
- Important: keys inside a `properties` object are treated as property names and MUST NOT be stripped by keyword match.
|
|
76
76
|
- Human-meaningful stripped keys (`pattern`, `format`, min/max constraints, `default`, `examples`, etc.) are appended to the sibling `description` as an Anthropic-style spill block: `{pattern: "^foo$", minimum: 0}`. Structural/meta keys such as `$ref`, `$defs`, and `additionalProperties` are not spilled.
|
|
77
77
|
|
|
78
78
|
2. **`type` arrays are normalized to scalar type + nullable marker**
|
|
79
79
|
- `type: ["T", "null"]` becomes `type: "T"` and `nullable: true`.
|
|
80
|
+
- `type: ["A", "B", …]` becomes `anyOf: [{ type: "A", …A keywords }, { type: "B", …B keywords }]`; type-specific keywords (`items`, `properties`, `minimum`, …) move into the branches whose type they constrain, and a `null` member becomes `nullable: true`.
|
|
81
|
+
- A node left with a scalar `type` MUST NOT keep another type's keywords: Gemini rejects `items` beside `type: "string"` with HTTP 400.
|
|
80
82
|
- Google expects scalar type, not `type[]`.
|
|
81
83
|
|
|
82
84
|
3. **`const` is converted to `enum`**
|
|
@@ -115,7 +117,7 @@ For Cloud Code Assist Claude tool declarations, schema MUST satisfy stricter con
|
|
|
115
117
|
|
|
116
118
|
1. Start with Google unsupported-key stripping behavior.
|
|
117
119
|
2. **`nullable` keyword MUST be stripped** in CCA Claude path.
|
|
118
|
-
3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker.
|
|
120
|
+
3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker; a multi-type array becomes `anyOf` branches that the combiner rules below collapse.
|
|
119
121
|
4. Human-meaningful stripped keys are appended to `description` with the same spill format used by the Google dispatcher.
|
|
120
122
|
|
|
121
123
|
### 3.3 Combiner/union normalization contract
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
|
|
17
17
|
$schema: true,
|
|
18
|
+
$id: true,
|
|
19
|
+
$anchor: true,
|
|
18
20
|
$ref: true,
|
|
19
21
|
$defs: true,
|
|
20
22
|
$dynamicRef: true,
|
|
@@ -28,6 +30,11 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
|
|
|
28
30
|
propertyNames: true,
|
|
29
31
|
minItems: true,
|
|
30
32
|
maxItems: true,
|
|
33
|
+
uniqueItems: true,
|
|
34
|
+
contains: true,
|
|
35
|
+
minContains: true,
|
|
36
|
+
maxContains: true,
|
|
37
|
+
additionalItems: true,
|
|
31
38
|
minLength: true,
|
|
32
39
|
maxLength: true,
|
|
33
40
|
minimum: true,
|
|
@@ -37,6 +44,9 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
|
|
|
37
44
|
multipleOf: true,
|
|
38
45
|
pattern: true,
|
|
39
46
|
format: true,
|
|
47
|
+
contentEncoding: true,
|
|
48
|
+
contentMediaType: true,
|
|
49
|
+
contentSchema: true,
|
|
40
50
|
dependencies: true,
|
|
41
51
|
dependentSchemas: true,
|
|
42
52
|
dependentRequired: true,
|
|
@@ -212,6 +222,8 @@ export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
|
|
|
212
222
|
*/
|
|
213
223
|
export const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
|
|
214
224
|
$schema: true,
|
|
225
|
+
$id: true,
|
|
226
|
+
$anchor: true,
|
|
215
227
|
$ref: true,
|
|
216
228
|
$defs: true,
|
|
217
229
|
$dynamicRef: true,
|
|
@@ -223,4 +235,12 @@ export const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
|
|
|
223
235
|
readOnly: true,
|
|
224
236
|
writeOnly: true,
|
|
225
237
|
$comment: true,
|
|
238
|
+
uniqueItems: true,
|
|
239
|
+
contains: true,
|
|
240
|
+
minContains: true,
|
|
241
|
+
maxContains: true,
|
|
242
|
+
additionalItems: true,
|
|
243
|
+
contentEncoding: true,
|
|
244
|
+
contentMediaType: true,
|
|
245
|
+
contentSchema: true,
|
|
226
246
|
};
|
|
@@ -36,7 +36,11 @@ export interface NormalizeSchemaOptions {
|
|
|
36
36
|
unsupportedFields: (key: string) => boolean;
|
|
37
37
|
normalizeFieldNames: boolean;
|
|
38
38
|
collapseNullFields: boolean;
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Target takes a scalar `type` only: `["T", "null"]` becomes `T` (+ `nullable`)
|
|
41
|
+
* and a multi-type array becomes one `anyOf` branch per type.
|
|
42
|
+
*/
|
|
43
|
+
scalarizeTypeArrays: boolean;
|
|
40
44
|
stripNullableKeyword: boolean;
|
|
41
45
|
autoPropertyOrdering: boolean;
|
|
42
46
|
ensureObjectProperties: boolean;
|
|
@@ -290,6 +294,51 @@ function preHandleNullFields(obj: JsonObject): JsonObject {
|
|
|
290
294
|
return out;
|
|
291
295
|
}
|
|
292
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Rewrites a multi-type `type` array (`["string", "array"]`) as one `anyOf`
|
|
299
|
+
* branch per non-null type, each carrying only the keywords that constrain its
|
|
300
|
+
* type. Collapsing to the first type instead narrows what the tool accepts and
|
|
301
|
+
* strands the other types' keywords: Gemini rejects `items` beside
|
|
302
|
+
* `type: "string"` with HTTP 400. A `null` member becomes `nullable: true`, as
|
|
303
|
+
* in the scalar collapse. Returns `obj` itself when it has fewer than two
|
|
304
|
+
* non-null types or already carries `anyOf`.
|
|
305
|
+
*/
|
|
306
|
+
function splitTypeArrayIntoAnyOf(obj: JsonObject): JsonObject {
|
|
307
|
+
if (!Array.isArray(obj.type) || Array.isArray(obj.anyOf)) return obj;
|
|
308
|
+
const types: string[] = [];
|
|
309
|
+
for (const type of obj.type) {
|
|
310
|
+
if (typeof type === "string" && type !== "null" && !types.includes(type)) types.push(type);
|
|
311
|
+
}
|
|
312
|
+
if (types.length < 2) return obj;
|
|
313
|
+
const branches = types.map(type => {
|
|
314
|
+
const schema: JsonObject = { type };
|
|
315
|
+
return { schema, keys: CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[type] ?? {} };
|
|
316
|
+
});
|
|
317
|
+
const out: JsonObject = {};
|
|
318
|
+
for (const key in obj) {
|
|
319
|
+
if (!Object.hasOwn(obj, key) || key === "type") continue;
|
|
320
|
+
const value = obj[key];
|
|
321
|
+
if (!Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key)) {
|
|
322
|
+
out[key] = value;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (const branch of branches) {
|
|
326
|
+
if (Object.hasOwn(branch.keys, key)) branch.schema[key] = value;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (obj.type.includes("null")) out.nullable = true;
|
|
330
|
+
out.anyOf = branches.map(branch => branch.schema);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Deletes keywords that constrain a JSON type other than `type`, e.g. `items` on a string node. */
|
|
335
|
+
function dropForeignTypeKeywords(schema: JsonObject, type: string): void {
|
|
336
|
+
const allowed = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[type] ?? {};
|
|
337
|
+
for (const key in schema) {
|
|
338
|
+
if (Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) && !Object.hasOwn(allowed, key)) delete schema[key];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
293
342
|
function outHasOwn(obj: JsonObject, key: string): boolean {
|
|
294
343
|
return Object.hasOwn(obj, key);
|
|
295
344
|
}
|
|
@@ -375,6 +424,9 @@ function normalizeSchemaNode(value: unknown, options: NormalizeSchemaWalkOptions
|
|
|
375
424
|
|
|
376
425
|
function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWalkOptions): unknown {
|
|
377
426
|
let obj = options.normalizeFieldNames && !options.insideSchemaMap ? applySnakeCaseRenames(value) : value;
|
|
427
|
+
if (options.scalarizeTypeArrays && !options.insideSchemaMap) {
|
|
428
|
+
obj = splitTypeArrayIntoAnyOf(obj);
|
|
429
|
+
}
|
|
378
430
|
if (options.collapseNullFields && !options.insideSchemaMap) {
|
|
379
431
|
obj = preHandleNullFields(obj);
|
|
380
432
|
}
|
|
@@ -479,13 +531,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa
|
|
|
479
531
|
: entry;
|
|
480
532
|
}
|
|
481
533
|
|
|
482
|
-
if (options.
|
|
534
|
+
if (options.scalarizeTypeArrays && Array.isArray(result.type)) {
|
|
483
535
|
const types = (result.type as unknown[]).filter((t): t is string => typeof t === "string");
|
|
484
536
|
const nonNull = types.filter(t => t !== "null");
|
|
485
537
|
if (types.includes("null") && !options.stripNullableKeyword) {
|
|
486
538
|
result.nullable = true;
|
|
487
539
|
}
|
|
488
540
|
result.type = nonNull[0] ?? types[0];
|
|
541
|
+
if (typeof result.type === "string") dropForeignTypeKeywords(result, result.type);
|
|
489
542
|
}
|
|
490
543
|
if (constValue !== undefined) {
|
|
491
544
|
const existingEnum = Array.isArray(result.enum) ? result.enum : [];
|
|
@@ -738,20 +791,8 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
738
791
|
const chosenType: string = nonNullTypes[0] ?? variantTypes[0];
|
|
739
792
|
nextSchema.type = chosenType;
|
|
740
793
|
const chosenTypeAllowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[chosenType] ?? {};
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// different type (e.g. `items` sibling on a now-string-typed schema).
|
|
744
|
-
for (const key in nextSchema) {
|
|
745
|
-
if (!Object.hasOwn(nextSchema, key)) continue;
|
|
746
|
-
if (key === "type") continue;
|
|
747
|
-
if (
|
|
748
|
-
Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) &&
|
|
749
|
-
!Object.hasOwn(chosenTypeAllowedKeys, key) &&
|
|
750
|
-
!Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key)
|
|
751
|
-
) {
|
|
752
|
-
delete nextSchema[key];
|
|
753
|
-
}
|
|
754
|
-
}
|
|
794
|
+
// Sibling keys copied from the parent may belong to a different type.
|
|
795
|
+
dropForeignTypeKeywords(nextSchema, chosenType);
|
|
755
796
|
|
|
756
797
|
for (const key in mergedVariantFields) {
|
|
757
798
|
if (!Object.hasOwn(mergedVariantFields, key)) continue;
|
|
@@ -1256,7 +1297,7 @@ export function normalizeSchemaForGoogle(value: unknown): unknown {
|
|
|
1256
1297
|
unsupportedFields: isGoogleUnsupportedSchemaField,
|
|
1257
1298
|
normalizeFieldNames: true,
|
|
1258
1299
|
collapseNullFields: true,
|
|
1259
|
-
|
|
1300
|
+
scalarizeTypeArrays: true,
|
|
1260
1301
|
stripNullableKeyword: false,
|
|
1261
1302
|
autoPropertyOrdering: true,
|
|
1262
1303
|
ensureObjectProperties: true,
|
|
@@ -1279,7 +1320,7 @@ export function normalizeSchemaForCCA(value: unknown): unknown {
|
|
|
1279
1320
|
unsupportedFields: isGoogleUnsupportedSchemaField,
|
|
1280
1321
|
normalizeFieldNames: true,
|
|
1281
1322
|
collapseNullFields: false,
|
|
1282
|
-
|
|
1323
|
+
scalarizeTypeArrays: true,
|
|
1283
1324
|
stripNullableKeyword: true,
|
|
1284
1325
|
autoPropertyOrdering: false,
|
|
1285
1326
|
ensureObjectProperties: true,
|
|
@@ -1302,7 +1343,7 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
|
|
|
1302
1343
|
unsupportedFields: isMcpUnsupportedSchemaField,
|
|
1303
1344
|
normalizeFieldNames: false,
|
|
1304
1345
|
collapseNullFields: false,
|
|
1305
|
-
|
|
1346
|
+
scalarizeTypeArrays: false,
|
|
1306
1347
|
foldOneOfIntoAnyOf: false,
|
|
1307
1348
|
stripNullableKeyword: true,
|
|
1308
1349
|
autoPropertyOrdering: false,
|
|
@@ -1330,8 +1371,9 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
|
|
|
1330
1371
|
* rejected; collapse to `enum` with an inferred scalar `type`.
|
|
1331
1372
|
* - `oneOf` is not an MFJS combinator (only `anyOf` is); residual `oneOf` is
|
|
1332
1373
|
* folded into `anyOf`.
|
|
1333
|
-
* - `type` must be a scalar string; `
|
|
1334
|
-
*
|
|
1374
|
+
* - `type` must be a scalar string; `["T", "null"]` reduces to `T` (the
|
|
1375
|
+
* `null` member is dropped — `nullable` is unsupported) and a multi-type
|
|
1376
|
+
* array becomes one typed `anyOf` branch per type.
|
|
1335
1377
|
* - Enum-bearing nodes get an inferred `type` (the idiomatic MFJS form; a bare
|
|
1336
1378
|
* `enum` is valid too) so `anyOf` branches always carry a `type`.
|
|
1337
1379
|
* - Validation/decorative keywords (`minItems`, `maxItems`, `maxLength`,
|
|
@@ -1354,7 +1396,7 @@ export function normalizeSchemaForMoonshot(value: unknown): unknown {
|
|
|
1354
1396
|
unsupportedFields: isMoonshotUnsupportedSchemaField,
|
|
1355
1397
|
normalizeFieldNames: false,
|
|
1356
1398
|
collapseNullFields: false,
|
|
1357
|
-
|
|
1399
|
+
scalarizeTypeArrays: true,
|
|
1358
1400
|
stripNullableKeyword: true,
|
|
1359
1401
|
autoPropertyOrdering: false,
|
|
1360
1402
|
ensureObjectProperties: false,
|