@oh-my-pi/pi-ai 16.3.4 → 16.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/types/auth-broker/client.d.ts +3 -1
- package/dist/types/auth-broker/remote-store.d.ts +6 -1
- package/dist/types/auth-broker/types.d.ts +13 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +46 -0
- package/dist/types/auth-storage.d.ts +40 -5
- package/dist/types/registry/oauth/types.d.ts +14 -0
- package/dist/types/types.d.ts +16 -0
- package/dist/types/utils/schema/normalize.d.ts +5 -0
- package/package.json +4 -4
- package/src/auth-broker/client.ts +41 -12
- package/src/auth-broker/remote-store.ts +189 -9
- package/src/auth-broker/server.ts +109 -7
- package/src/auth-broker/types.ts +22 -1
- package/src/auth-broker/wire-schemas.ts +22 -0
- package/src/auth-storage.ts +300 -24
- package/src/providers/ollama.ts +2 -2
- package/src/providers/openai-codex-responses.ts +6 -1
- package/src/providers/openai-completions.ts +12 -1
- package/src/providers/openai-responses.ts +10 -1
- package/src/registry/oauth/callback-server.ts +97 -9
- package/src/registry/oauth/types.ts +14 -0
- package/src/types.ts +18 -0
- package/src/usage/claude.ts +28 -8
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/schema/normalize.ts +131 -0
- package/src/utils/validation.ts +295 -0
|
@@ -17,6 +17,14 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
17
17
|
const DEFAULT_TIMEOUT = 300_000;
|
|
18
18
|
const DEFAULT_HOSTNAME = "localhost";
|
|
19
19
|
const CALLBACK_PATH = "/callback";
|
|
20
|
+
/**
|
|
21
|
+
* Path served by {@link OAuthCallbackFlow} that 302-redirects to the pending
|
|
22
|
+
* authorization URL. Kept out of {@link OAuthCallbackFlowOptions} because it
|
|
23
|
+
* lives on the loopback callback server alongside {@link CALLBACK_PATH} and
|
|
24
|
+
* must never clash with a provider-registered redirect URI (all known
|
|
25
|
+
* providers register `/callback`-shaped paths).
|
|
26
|
+
*/
|
|
27
|
+
const LAUNCH_PATH = "/launch";
|
|
20
28
|
|
|
21
29
|
export type CallbackResult = { code: string; state: string };
|
|
22
30
|
|
|
@@ -54,6 +62,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
54
62
|
allowPortFallback: boolean;
|
|
55
63
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
56
64
|
#callbackReject?: (error: string) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Authorization URL the `/launch` route currently redirects to. Set by
|
|
67
|
+
* {@link login} after {@link generateAuthUrl} and before {@link OAuthController.onAuth}
|
|
68
|
+
* fires, cleared when the server stops. `undefined` before the flow reaches
|
|
69
|
+
* that point and after it finishes, so `/launch` returns 503 rather than
|
|
70
|
+
* a stale URL.
|
|
71
|
+
*/
|
|
72
|
+
#pendingAuthUrl?: string;
|
|
57
73
|
|
|
58
74
|
constructor(
|
|
59
75
|
ctrl: OAuthController,
|
|
@@ -120,7 +136,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
120
136
|
this.#throwIfCancelled();
|
|
121
137
|
|
|
122
138
|
// Start callback server first to get actual redirect URI
|
|
123
|
-
const { server, redirectUri } = await this.#startCallbackServer(state);
|
|
139
|
+
const { server, redirectUri, launchUrl } = await this.#startCallbackServer(state);
|
|
124
140
|
|
|
125
141
|
try {
|
|
126
142
|
this.#throwIfCancelled();
|
|
@@ -128,8 +144,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
128
144
|
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
129
145
|
this.#throwIfCancelled();
|
|
130
146
|
|
|
147
|
+
// Publish the auth URL to the `/launch` route BEFORE handing it to
|
|
148
|
+
// callers. `onAuth` immediately renders a UI that advertises the
|
|
149
|
+
// launch URL as a copy target, so `/launch` must already resolve if
|
|
150
|
+
// the user clicks/pastes it during the same render pass.
|
|
151
|
+
this.#pendingAuthUrl = authUrl;
|
|
152
|
+
|
|
131
153
|
// Notify controller that auth is ready
|
|
132
|
-
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
154
|
+
this.ctrl.onAuth?.({ url: authUrl, launchUrl, instructions });
|
|
133
155
|
this.ctrl.onProgress?.("Waiting for browser authentication...");
|
|
134
156
|
|
|
135
157
|
// Wait for callback or manual input
|
|
@@ -140,21 +162,33 @@ export abstract class OAuthCallbackFlow {
|
|
|
140
162
|
|
|
141
163
|
return await this.exchangeToken(code, state, redirectUri);
|
|
142
164
|
} finally {
|
|
165
|
+
this.#pendingAuthUrl = undefined;
|
|
143
166
|
server.stop();
|
|
144
167
|
}
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
/**
|
|
148
171
|
* Start callback server, trying preferred port first, falling back to random.
|
|
172
|
+
* `launchUrl` is `undefined` when the caller configured `callbackPath` to
|
|
173
|
+
* collide with {@link LAUNCH_PATH} — the callback handler resolves the real
|
|
174
|
+
* callback in that case, so advertising a self-redirecting URL would be
|
|
175
|
+
* incorrect.
|
|
149
176
|
*/
|
|
150
|
-
async #startCallbackServer(
|
|
177
|
+
async #startCallbackServer(
|
|
178
|
+
expectedState: string,
|
|
179
|
+
): Promise<{ server: Bun.Server<unknown>; redirectUri: string; launchUrl: string | undefined }> {
|
|
151
180
|
try {
|
|
152
181
|
const server = this.#createServer(this.preferredPort, expectedState);
|
|
182
|
+
// `preferredPort: 0` opts into a random port — read the actual bound
|
|
183
|
+
// port from the server so both the redirect URI and launch URL point at
|
|
184
|
+
// a reachable socket, not the sentinel.
|
|
185
|
+
const actualPort = this.#resolveServerPort(server);
|
|
186
|
+
const launchUrl = this.#launchUrlIfSafe(actualPort);
|
|
153
187
|
if (this.redirectUri) {
|
|
154
|
-
return { server, redirectUri: this.redirectUri };
|
|
188
|
+
return { server, redirectUri: this.redirectUri, launchUrl };
|
|
155
189
|
}
|
|
156
|
-
const redirectUri = `http://${this.callbackHostname}:${
|
|
157
|
-
return { server, redirectUri };
|
|
190
|
+
const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
|
|
191
|
+
return { server, redirectUri, launchUrl };
|
|
158
192
|
} catch (cause) {
|
|
159
193
|
if (this.redirectUri) {
|
|
160
194
|
throw new AIError.ConfigurationError(
|
|
@@ -169,11 +203,49 @@ export abstract class OAuthCallbackFlow {
|
|
|
169
203
|
);
|
|
170
204
|
}
|
|
171
205
|
const server = this.#createServer(0, expectedState);
|
|
172
|
-
const actualPort = server
|
|
206
|
+
const actualPort = this.#resolveServerPort(server);
|
|
173
207
|
const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
|
|
208
|
+
const launchUrl = this.#launchUrlIfSafe(actualPort);
|
|
174
209
|
this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
|
|
175
|
-
return { server, redirectUri };
|
|
210
|
+
return { server, redirectUri, launchUrl };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read the numeric port a callback server bound to. `Bun.Server.port` is
|
|
216
|
+
* declared `number | undefined` because Unix-socket servers have no port,
|
|
217
|
+
* but every callback flow uses TCP; a missing port here indicates a
|
|
218
|
+
* configuration error rather than a fallback case.
|
|
219
|
+
*/
|
|
220
|
+
#resolveServerPort(server: Bun.Server<unknown>): number {
|
|
221
|
+
const port = server.port;
|
|
222
|
+
if (typeof port !== "number") {
|
|
223
|
+
throw new AIError.ConfigurationError(
|
|
224
|
+
"OAuth callback server bound to a non-TCP endpoint; expected a numeric port. Check `oauth.callbackPort`/`oauth.redirectUri`.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return port;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the `/launch` URL served by the callback server bound to `port`, or
|
|
232
|
+
* `undefined` when the configured `callbackPath` (or a `redirectUri` whose
|
|
233
|
+
* pathname resolves to {@link LAUNCH_PATH}) would collide with the launch
|
|
234
|
+
* route. Kept short (~30 chars) so UIs can advertise it as a
|
|
235
|
+
* viewport-truncation-safe copy target for the full authorization URL.
|
|
236
|
+
*/
|
|
237
|
+
#launchUrlIfSafe(port: number): string | undefined {
|
|
238
|
+
if (this.callbackPath === LAUNCH_PATH) return undefined;
|
|
239
|
+
if (this.redirectUri) {
|
|
240
|
+
try {
|
|
241
|
+
if (new URL(this.redirectUri).pathname === LAUNCH_PATH) return undefined;
|
|
242
|
+
} catch {
|
|
243
|
+
// A non-parseable redirectUri (e.g. `vscode://...` handled elsewhere)
|
|
244
|
+
// can't collide with an HTTP `/launch` route — fall through and
|
|
245
|
+
// advertise the launch URL against the loopback server.
|
|
246
|
+
}
|
|
176
247
|
}
|
|
248
|
+
return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
|
|
177
249
|
}
|
|
178
250
|
|
|
179
251
|
/**
|
|
@@ -190,12 +262,28 @@ export abstract class OAuthCallbackFlow {
|
|
|
190
262
|
}
|
|
191
263
|
|
|
192
264
|
/**
|
|
193
|
-
* Handle OAuth callback HTTP request.
|
|
265
|
+
* Handle OAuth callback HTTP request. Two routes on the same loopback server:
|
|
266
|
+
* - `callbackPath` (default `/callback`) — the provider redirect target.
|
|
267
|
+
* - {@link LAUNCH_PATH} (`/launch`) — 302 to the pending authorization URL so
|
|
268
|
+
* viewport-safe copy targets can survive TUI truncation.
|
|
269
|
+
*
|
|
270
|
+
* `callbackPath` wins any collision: an OMP config that pins the provider
|
|
271
|
+
* redirect at `/launch` (via `oauth.callbackPath` or a loopback
|
|
272
|
+
* `oauth.redirectUri`) must resolve the callback normally rather than
|
|
273
|
+
* self-redirect. `#startCallbackServer` also suppresses `launchUrl` in that
|
|
274
|
+
* case, so the launch route is never advertised when it would collide.
|
|
194
275
|
*/
|
|
195
276
|
#handleCallback(req: Request, expectedState: string): Response {
|
|
196
277
|
const url = new URL(req.url);
|
|
197
278
|
|
|
198
279
|
if (url.pathname !== this.callbackPath) {
|
|
280
|
+
if (url.pathname === LAUNCH_PATH) {
|
|
281
|
+
const pending = this.#pendingAuthUrl;
|
|
282
|
+
if (!pending) {
|
|
283
|
+
return new Response("OAuth launch URL is no longer active", { status: 503 });
|
|
284
|
+
}
|
|
285
|
+
return Response.redirect(pending, 302);
|
|
286
|
+
}
|
|
199
287
|
return new Response("Not Found", { status: 404 });
|
|
200
288
|
}
|
|
201
289
|
|
|
@@ -23,7 +23,21 @@ export type OAuthPrompt = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
export type OAuthAuthInfo = {
|
|
26
|
+
/**
|
|
27
|
+
* Full authorization URL. Suitable for direct browser launch, OSC 8
|
|
28
|
+
* hyperlinks, and clipboard when the target UI can guarantee the full
|
|
29
|
+
* string reaches the user unmodified.
|
|
30
|
+
*/
|
|
26
31
|
url: string;
|
|
32
|
+
/**
|
|
33
|
+
* Short loopback URL that 302-redirects to {@link url}. Provided by flows
|
|
34
|
+
* that host the redirect on the same callback server they already run
|
|
35
|
+
* ({@link OAuthCallbackFlow}). UIs SHOULD prefer this as the copy target
|
|
36
|
+
* so viewport truncation cannot corrupt OAuth query parameters. Undefined
|
|
37
|
+
* for flows without a loopback callback server (device code, paste-code
|
|
38
|
+
* providers with fixed non-loopback redirects, etc.).
|
|
39
|
+
*/
|
|
40
|
+
launchUrl?: string;
|
|
27
41
|
instructions?: string;
|
|
28
42
|
};
|
|
29
43
|
|
package/src/types.ts
CHANGED
|
@@ -647,6 +647,23 @@ export interface DeveloperMessage {
|
|
|
647
647
|
timestamp: number; // Unix timestamp in milliseconds
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
|
|
651
|
+
|
|
652
|
+
export interface AssistantRetryRecovery {
|
|
653
|
+
kind: "auto-retry";
|
|
654
|
+
status: "recovered";
|
|
655
|
+
attempt: number;
|
|
656
|
+
recoveredAt: string;
|
|
657
|
+
recovery: AssistantRetryRecoveryKind;
|
|
658
|
+
note: string;
|
|
659
|
+
supersededBy?: {
|
|
660
|
+
timestamp: number;
|
|
661
|
+
responseId?: string;
|
|
662
|
+
provider: string;
|
|
663
|
+
model: string;
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
650
667
|
export interface ContextSnapshot {
|
|
651
668
|
promptTokens: number; // authoritative provider prompt/input tokens
|
|
652
669
|
nonMessageTokens: number; // estimated non-message total at send time
|
|
@@ -660,6 +677,7 @@ export interface AssistantMessage {
|
|
|
660
677
|
provider: Provider;
|
|
661
678
|
model: string;
|
|
662
679
|
contextSnapshot?: ContextSnapshot;
|
|
680
|
+
retryRecovery?: AssistantRetryRecovery;
|
|
663
681
|
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
|
|
664
682
|
/**
|
|
665
683
|
* Name of the upstream provider an aggregator routed this request to, as
|
package/src/usage/claude.ts
CHANGED
|
@@ -615,20 +615,40 @@ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRanki
|
|
|
615
615
|
}
|
|
616
616
|
|
|
617
617
|
/**
|
|
618
|
-
*
|
|
619
|
-
* (
|
|
620
|
-
*
|
|
621
|
-
*
|
|
618
|
+
* A Fable/Mythos weekly row is trusted for gating only at full exhaustion
|
|
619
|
+
* (server `exhausted` status or used fraction >= 1) with a live reset
|
|
620
|
+
* timestamp. Anything below that stays untrusted: the counters are
|
|
621
|
+
* notoriously unreliable short of the cap (they report high utilization
|
|
622
|
+
* while the account can still serve requests).
|
|
623
|
+
*/
|
|
624
|
+
function isConfirmedExhaustedTierRow(limit: UsageLimit, nowMs: number): boolean {
|
|
625
|
+
const resetsAt = limit.window?.resetsAt;
|
|
626
|
+
if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return false;
|
|
627
|
+
if (limit.status === "exhausted") return true;
|
|
628
|
+
const fraction = resolveUsedFraction(limit);
|
|
629
|
+
return typeof fraction === "number" && fraction >= 1;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Scope limits for proactive hard-blocking (gating). Fable and Mythos tier
|
|
634
|
+
* weekly caps participate only when {@link isConfirmedExhaustedTierRow}
|
|
635
|
+
* confirms them, so a confirmed-dead account is skipped up front and a
|
|
636
|
+
* reactive 429 block extends to the tier reset in markUsageLimitReached,
|
|
637
|
+
* while unconfirmed rows remain ranking pressure only via
|
|
638
|
+
* scopeClaudeLimitsForModel.
|
|
622
639
|
*/
|
|
623
640
|
function scopeClaudeLimitsForModelHardBlock(
|
|
624
641
|
report: UsageReport,
|
|
625
642
|
context: CredentialRankingContext | undefined,
|
|
626
643
|
): UsageLimit[] {
|
|
627
644
|
const kind = getClaudeModelKind(context);
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
645
|
+
const requireConfirmedTierRow = kind === "fable" || kind === "mythos";
|
|
646
|
+
const nowMs = Date.now();
|
|
647
|
+
return report.limits.filter(limit => {
|
|
648
|
+
if (limit.scope.shared === true) return true;
|
|
649
|
+
if (kind === undefined || limit.scope.tier !== kind) return false;
|
|
650
|
+
return !requireConfirmedTierRow || isConfirmedExhaustedTierRow(limit, nowMs);
|
|
651
|
+
});
|
|
632
652
|
}
|
|
633
653
|
|
|
634
654
|
function rankingUsedFraction(limit: UsageLimit): number {
|
|
@@ -53,6 +53,7 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
|
|
|
53
53
|
|
|
54
54
|
6. **Provider payload strict flag must match effective strictness**
|
|
55
55
|
- Callers MUST send `strict: true` only if enforcement succeeded (`effectiveStrict === true`).
|
|
56
|
+
- Callers MUST preserve an author's explicit `tool.strict === false` on the wire so that `strict: false` and omitted `strict` remain distinguishable — some OpenAI-compat backends over-fill optional fields when the flag is absent but respect it when set to `false` (#4336). Exceptions: `openai-responses` emits explicit `false` only while its `strictMode` gate and `PI_NO_STRICT` permit sending the strict field; `openai-codex-responses` gates explicit `false` on `!PI_NO_STRICT` so the documented global bypass keeps the `strict` key off the wire for Codex proxies that reject it; `openai-completions` emits explicit `false` only in `toolStrictMode === "mixed"` with `compat.supportsStrictMode !== false`, because the `all_strict → none` collapse and providers that reject the `strict` key rely on uniform absence.
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
|
@@ -1014,6 +1014,137 @@ export function normalizeSchemaForMoonshot(value: unknown): unknown {
|
|
|
1014
1014
|
});
|
|
1015
1015
|
}
|
|
1016
1016
|
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
// Ollama — Go schema parser compatibility
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
const OLLAMA_SCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
|
1022
|
+
const OLLAMA_SCHEMA_MAP_KEYS = new Set([
|
|
1023
|
+
"properties",
|
|
1024
|
+
"patternProperties",
|
|
1025
|
+
"dependencies",
|
|
1026
|
+
"dependentSchemas",
|
|
1027
|
+
"$defs",
|
|
1028
|
+
"definitions",
|
|
1029
|
+
]);
|
|
1030
|
+
const OLLAMA_SCHEMA_VALUE_KEYS = new Set([
|
|
1031
|
+
"items",
|
|
1032
|
+
"additionalItems",
|
|
1033
|
+
"contains",
|
|
1034
|
+
"contentSchema",
|
|
1035
|
+
"propertyNames",
|
|
1036
|
+
"if",
|
|
1037
|
+
"then",
|
|
1038
|
+
"else",
|
|
1039
|
+
"not",
|
|
1040
|
+
"additionalProperties",
|
|
1041
|
+
"unevaluatedItems",
|
|
1042
|
+
"unevaluatedProperties",
|
|
1043
|
+
]);
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Widened stand-in for a `true` / `{}` subschema in an Ollama-bound tool.
|
|
1047
|
+
*
|
|
1048
|
+
* `toolWireSchema()` normalizes empty schemas to boolean `true` upstream so
|
|
1049
|
+
* grammar-constrained samplers (llama.cpp, etc.) don't treat `{}` as
|
|
1050
|
+
* "generate an empty object" (issue #1179). Ollama's Go tool parser can't
|
|
1051
|
+
* unmarshal a boolean into its object-shaped `Schema` struct, so this
|
|
1052
|
+
* sanitizer replaces every open subschema with an explicit union of every
|
|
1053
|
+
* primitive JSON type. Both invariants survive: the wire has no boolean
|
|
1054
|
+
* subschema (Go accepts it), and llama.cpp's grammar sees a real value
|
|
1055
|
+
* union rather than a closed empty object.
|
|
1056
|
+
*/
|
|
1057
|
+
const OLLAMA_OPEN_SUBSCHEMA_WIDENING = Object.freeze({
|
|
1058
|
+
anyOf: [
|
|
1059
|
+
{ type: "string" },
|
|
1060
|
+
{ type: "number" },
|
|
1061
|
+
{ type: "boolean" },
|
|
1062
|
+
{ type: "object" },
|
|
1063
|
+
{ type: "array" },
|
|
1064
|
+
{ type: "null" },
|
|
1065
|
+
],
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Rewrites standard JSON Schema forms that Ollama's Go `/api/chat` tool parser
|
|
1070
|
+
* cannot unmarshal into its object-shaped `Schema` struct.
|
|
1071
|
+
*/
|
|
1072
|
+
export function sanitizeSchemaForOllama(schema: JsonObject): JsonObject {
|
|
1073
|
+
const normalizeNode = (value: unknown): unknown => {
|
|
1074
|
+
if (value === true) return OLLAMA_OPEN_SUBSCHEMA_WIDENING;
|
|
1075
|
+
if (value === false) return { not: OLLAMA_OPEN_SUBSCHEMA_WIDENING };
|
|
1076
|
+
if (!isJsonObject(value)) {
|
|
1077
|
+
if (!Array.isArray(value)) return value;
|
|
1078
|
+
let changed = false;
|
|
1079
|
+
const output = value.map(item => {
|
|
1080
|
+
const next = normalizeNode(item);
|
|
1081
|
+
if (next !== item) changed = true;
|
|
1082
|
+
return next;
|
|
1083
|
+
});
|
|
1084
|
+
return changed ? output : value;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
let changed = false;
|
|
1088
|
+
const output: JsonObject = {};
|
|
1089
|
+
let typeAlternatives: JsonObject[] | undefined;
|
|
1090
|
+
for (const key in value) {
|
|
1091
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
1092
|
+
const child = value[key];
|
|
1093
|
+
if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") {
|
|
1094
|
+
changed = true;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (key === "type" && Array.isArray(child)) {
|
|
1098
|
+
const variants = child.filter((entry): entry is string => typeof entry === "string");
|
|
1099
|
+
const uniqueVariants = [...new Set(variants)];
|
|
1100
|
+
const nonNull = uniqueVariants.filter(entry => entry !== "null");
|
|
1101
|
+
if (nonNull.length <= 1) {
|
|
1102
|
+
output.type = nonNull[0] ?? uniqueVariants[0] ?? child[0];
|
|
1103
|
+
} else {
|
|
1104
|
+
typeAlternatives = uniqueVariants.map(entry => ({ type: entry }));
|
|
1105
|
+
}
|
|
1106
|
+
changed = true;
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
let next = child;
|
|
1111
|
+
if (OLLAMA_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
|
|
1112
|
+
let mapChanged = false;
|
|
1113
|
+
const mapOutput: JsonObject = {};
|
|
1114
|
+
for (const childKey in child) {
|
|
1115
|
+
if (!Object.hasOwn(child, childKey)) continue;
|
|
1116
|
+
const mapChild = child[childKey];
|
|
1117
|
+
const normalizedChild = normalizeNode(mapChild);
|
|
1118
|
+
if (normalizedChild !== mapChild) mapChanged = true;
|
|
1119
|
+
mapOutput[childKey] = normalizedChild;
|
|
1120
|
+
}
|
|
1121
|
+
next = mapChanged ? mapOutput : child;
|
|
1122
|
+
} else if (OLLAMA_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
|
|
1123
|
+
let arrayChanged = false;
|
|
1124
|
+
const arrayOutput = child.map(item => {
|
|
1125
|
+
const normalizedItem = normalizeNode(item);
|
|
1126
|
+
if (normalizedItem !== item) arrayChanged = true;
|
|
1127
|
+
return normalizedItem;
|
|
1128
|
+
});
|
|
1129
|
+
next = arrayChanged ? arrayOutput : child;
|
|
1130
|
+
} else if (OLLAMA_SCHEMA_VALUE_KEYS.has(key)) {
|
|
1131
|
+
next = normalizeNode(child);
|
|
1132
|
+
}
|
|
1133
|
+
if (next !== child) changed = true;
|
|
1134
|
+
output[key] = next;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (typeAlternatives) {
|
|
1138
|
+
const existingAllOf = output.allOf;
|
|
1139
|
+
const typeUnion = { anyOf: typeAlternatives };
|
|
1140
|
+
output.allOf = Array.isArray(existingAllOf) ? [typeUnion, ...existingAllOf] : [typeUnion];
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return changed ? output : value;
|
|
1144
|
+
};
|
|
1145
|
+
return normalizeNode(schema) as JsonObject;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1017
1148
|
// ---------------------------------------------------------------------------
|
|
1018
1149
|
// OpenAI Responses — schema-valued normalization
|
|
1019
1150
|
// ---------------------------------------------------------------------------
|