pi-fireworks-provider 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +4 -0
- package/.pi/messenger/channels/memory.jsonl +1 -0
- package/.pi/messenger/session-id +1 -0
- package/AGENTS.md +56 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/custom-models.json +151 -0
- package/index.ts +429 -0
- package/models.json +424 -0
- package/package.json +33 -0
- package/patch.json +418 -0
- package/scripts/update-models.js +349 -0
package/index.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fireworks Provider Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers Fireworks as a custom provider using the openai-completions API.
|
|
5
|
+
* Base URL: https://api.fireworks.ai/inference/v1
|
|
6
|
+
*
|
|
7
|
+
* Model resolution strategy: Stale-While-Revalidate
|
|
8
|
+
* 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
|
|
9
|
+
* 2. Revalidate in background: live API /models → merge with embedded → cache → hot-swap
|
|
10
|
+
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
11
|
+
*
|
|
12
|
+
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* # Option 1: Store in auth.json (recommended)
|
|
16
|
+
* # Add to ~/.pi/agent/auth.json:
|
|
17
|
+
* # "fireworks": { "type": "api_key", "key": "your-api-key" }
|
|
18
|
+
*
|
|
19
|
+
* # Option 2: Set as environment variable
|
|
20
|
+
* export FIREWORKS_API_KEY=your-api-key
|
|
21
|
+
*
|
|
22
|
+
* # Run pi with the extension
|
|
23
|
+
* pi -e /path/to/pi-fireworks-provider
|
|
24
|
+
*
|
|
25
|
+
* Then use /model to select from available models
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import modelsData from "./models.json" with { type: "json" };
|
|
30
|
+
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
31
|
+
import patchData from "./patch.json" with { type: "json" };
|
|
32
|
+
import fs from "fs";
|
|
33
|
+
import path from "path";
|
|
34
|
+
|
|
35
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface JsonModel {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
reasoning: boolean;
|
|
41
|
+
input: string[];
|
|
42
|
+
cost: {
|
|
43
|
+
input: number;
|
|
44
|
+
output: number;
|
|
45
|
+
cacheRead: number;
|
|
46
|
+
cacheWrite: number;
|
|
47
|
+
};
|
|
48
|
+
contextWindow: number;
|
|
49
|
+
maxTokens: number;
|
|
50
|
+
compat?: {
|
|
51
|
+
supportsDeveloperRole?: boolean;
|
|
52
|
+
supportsStore?: boolean;
|
|
53
|
+
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
|
54
|
+
thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
|
|
55
|
+
supportsReasoningEffort?: boolean;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PatchEntry {
|
|
60
|
+
name?: string;
|
|
61
|
+
reasoning?: boolean;
|
|
62
|
+
input?: string[];
|
|
63
|
+
cost?: {
|
|
64
|
+
input?: number;
|
|
65
|
+
output?: number;
|
|
66
|
+
cacheRead?: number;
|
|
67
|
+
cacheWrite?: number;
|
|
68
|
+
};
|
|
69
|
+
contextWindow?: number;
|
|
70
|
+
maxTokens?: number;
|
|
71
|
+
compat?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type PatchData = Record<string, PatchEntry>;
|
|
75
|
+
|
|
76
|
+
// ─── Patch Application ────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
|
|
79
|
+
const result = { ...model };
|
|
80
|
+
|
|
81
|
+
if (patch.name !== undefined) result.name = patch.name;
|
|
82
|
+
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
|
|
83
|
+
if (patch.input !== undefined) result.input = patch.input;
|
|
84
|
+
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
|
|
85
|
+
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
|
|
86
|
+
|
|
87
|
+
if (patch.cost) {
|
|
88
|
+
result.cost = {
|
|
89
|
+
input: patch.cost.input ?? result.cost.input,
|
|
90
|
+
output: patch.cost.output ?? result.cost.output,
|
|
91
|
+
cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
|
|
92
|
+
cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (patch.compat) {
|
|
96
|
+
result.compat = { ...(result.compat || {}), ...patch.compat };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!result.reasoning && result.compat?.thinkingFormat) {
|
|
100
|
+
delete result.compat.thinkingFormat;
|
|
101
|
+
}
|
|
102
|
+
if (result.compat && Object.keys(result.compat).length === 0) {
|
|
103
|
+
delete result.compat;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Full pipeline: base models → patch → custom → result */
|
|
110
|
+
function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
|
|
111
|
+
const modelMap = new Map<string, JsonModel>();
|
|
112
|
+
|
|
113
|
+
for (const model of base) {
|
|
114
|
+
modelMap.set(model.id, model);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [id, patchEntry] of Object.entries(patch)) {
|
|
118
|
+
const existing = modelMap.get(id);
|
|
119
|
+
if (existing) {
|
|
120
|
+
modelMap.set(id, applyPatch(existing, patchEntry));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const model of custom) {
|
|
125
|
+
const existing = modelMap.get(model.id);
|
|
126
|
+
const patchEntry = patch[model.id];
|
|
127
|
+
if (existing && patchEntry) {
|
|
128
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
129
|
+
} else if (existing) {
|
|
130
|
+
modelMap.set(model.id, model);
|
|
131
|
+
} else if (patchEntry) {
|
|
132
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
133
|
+
} else {
|
|
134
|
+
modelMap.set(model.id, model);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Array.from(modelMap.values());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
const PROVIDER_ID = "fireworks";
|
|
144
|
+
const BASE_URL = "https://api.fireworks.ai/inference/v1";
|
|
145
|
+
const MODELS_URL = `${BASE_URL}/models`;
|
|
146
|
+
const CACHE_DIR = path.join(getAgentDir(), "cache");
|
|
147
|
+
const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
|
|
148
|
+
const LIVE_FETCH_TIMEOUT_MS = 8000;
|
|
149
|
+
|
|
150
|
+
/** Filter: only keep chat models with useful metadata. */
|
|
151
|
+
function isChatModel(apiModel: any): boolean {
|
|
152
|
+
return apiModel.supports_chat === true || apiModel.kind === "HF_BASE_MODEL";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Transform a model from the Fireworks /v1/models API to JsonModel format. */
|
|
156
|
+
function transformApiModel(apiModel: any): JsonModel | null {
|
|
157
|
+
if (!isChatModel(apiModel)) return null;
|
|
158
|
+
return {
|
|
159
|
+
id: apiModel.id,
|
|
160
|
+
name: apiModel.id,
|
|
161
|
+
reasoning: false,
|
|
162
|
+
input: apiModel.supports_image_input ? ["text", "image"] : ["text"],
|
|
163
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
164
|
+
contextWindow: apiModel.context_length || 0,
|
|
165
|
+
maxTokens: 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(MODELS_URL, {
|
|
172
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
173
|
+
signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) return null;
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
const apiModels = Array.isArray(data) ? data : (data.data || []);
|
|
178
|
+
if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
|
|
179
|
+
return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadCachedModels(): JsonModel[] | null {
|
|
186
|
+
try {
|
|
187
|
+
const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
|
|
188
|
+
return Array.isArray(data) ? data : null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cacheModels(models: JsonModel[]): void {
|
|
195
|
+
try {
|
|
196
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
197
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
198
|
+
} catch {
|
|
199
|
+
// Cache write failure is non-fatal
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
|
|
204
|
+
const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
|
|
205
|
+
const seen = new Set<string>();
|
|
206
|
+
const result: JsonModel[] = [];
|
|
207
|
+
for (const liveModel of liveModels) {
|
|
208
|
+
const embedded = embeddedMap.get(liveModel.id);
|
|
209
|
+
seen.add(liveModel.id);
|
|
210
|
+
if (embedded) {
|
|
211
|
+
// Self-heal: live API pricing is authoritative field-by-field. Prefer the
|
|
212
|
+
// live cost when the API reports it (non-zero); fall back to embedded when
|
|
213
|
+
// the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
|
|
214
|
+
// providers whose /models endpoint exposes no pricing keep their curated
|
|
215
|
+
// cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
|
|
216
|
+
result.push({
|
|
217
|
+
...liveModel,
|
|
218
|
+
...embedded,
|
|
219
|
+
cost: {
|
|
220
|
+
input: liveModel.cost.input || embedded.cost.input,
|
|
221
|
+
output: liveModel.cost.output || embedded.cost.output,
|
|
222
|
+
cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
|
|
223
|
+
cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
|
|
224
|
+
},
|
|
225
|
+
contextWindow: liveModel.contextWindow || embedded.contextWindow,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
result.push(liveModel);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Append any embedded models that the live API didn't return
|
|
232
|
+
for (const em of embeddedModels) {
|
|
233
|
+
if (!seen.has(em.id)) {
|
|
234
|
+
result.push(em);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
241
|
+
const cached = loadCachedModels();
|
|
242
|
+
if (!cached || cached.length === 0) return embeddedModels;
|
|
243
|
+
|
|
244
|
+
// Merge embedded models that are missing from cache (newly added models)
|
|
245
|
+
const cachedMap = new Map(cached.map(m => [m.id, m]));
|
|
246
|
+
for (const em of embeddedModels) {
|
|
247
|
+
if (!cachedMap.has(em.id)) {
|
|
248
|
+
cached.push(em);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return cached;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
|
|
255
|
+
if (!apiKey) return null;
|
|
256
|
+
const liveModels = await fetchLiveModels(apiKey, signal);
|
|
257
|
+
if (!liveModels || liveModels.length === 0) return null;
|
|
258
|
+
const merged = mergeWithEmbedded(liveModels, embeddedModels);
|
|
259
|
+
cacheModels(merged);
|
|
260
|
+
return merged;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
let cachedApiKey: string | undefined;
|
|
266
|
+
let revalidateAbort: AbortController | null = null;
|
|
267
|
+
|
|
268
|
+
async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
|
|
269
|
+
cachedApiKey = await modelRegistry.getApiKeyForProvider("fireworks") ?? undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Kimi Regex Anchor Bleed Fix ──────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
function isFireworksKimiModel(model: any): boolean {
|
|
275
|
+
if (!model || model.provider !== "fireworks") return false;
|
|
276
|
+
return /kimi-k2/i.test(model.id);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function sanitizePattern(pattern: string): string | undefined {
|
|
280
|
+
// Alternation combined with anchors is the known trigger for Kimi's
|
|
281
|
+
// regex anchor bleed bug. Remove the entire pattern in that case.
|
|
282
|
+
if (pattern.includes("|") && (pattern.includes("^") || pattern.includes("$"))) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
// For simple patterns, strip anchors so they can't leak into values.
|
|
286
|
+
const stripped = pattern.replace(/\^|\$/g, "");
|
|
287
|
+
return stripped.length > 0 ? stripped : undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function sanitizeSchemaForKimi(schema: any): any {
|
|
291
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
292
|
+
if (Array.isArray(schema)) {
|
|
293
|
+
return schema.map((item) => sanitizeSchemaForKimi(item));
|
|
294
|
+
}
|
|
295
|
+
const result: Record<string, any> = {};
|
|
296
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
297
|
+
if (key === "pattern" && typeof value === "string") {
|
|
298
|
+
const sanitized = sanitizePattern(value);
|
|
299
|
+
if (sanitized !== undefined) {
|
|
300
|
+
result[key] = sanitized;
|
|
301
|
+
}
|
|
302
|
+
// If sanitized is undefined, we omit the key entirely.
|
|
303
|
+
} else if (value && typeof value === "object") {
|
|
304
|
+
result[key] = sanitizeSchemaForKimi(value);
|
|
305
|
+
} else {
|
|
306
|
+
result[key] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function stripAnchorBleedInPlace(obj: Record<string, unknown>): void {
|
|
313
|
+
for (const key of Object.keys(obj)) {
|
|
314
|
+
const value = obj[key];
|
|
315
|
+
if (typeof value === "string") {
|
|
316
|
+
let s = value;
|
|
317
|
+
while (s.startsWith("^")) s = s.slice(1);
|
|
318
|
+
while (s.endsWith("$")) s = s.slice(0, -1);
|
|
319
|
+
obj[key] = s;
|
|
320
|
+
} else if (Array.isArray(value)) {
|
|
321
|
+
for (let i = 0; i < value.length; i++) {
|
|
322
|
+
const item = value[i];
|
|
323
|
+
if (typeof item === "string") {
|
|
324
|
+
let s = item;
|
|
325
|
+
while (s.startsWith("^")) s = s.slice(1);
|
|
326
|
+
while (s.endsWith("$")) s = s.slice(0, -1);
|
|
327
|
+
value[i] = s;
|
|
328
|
+
} else if (item && typeof item === "object") {
|
|
329
|
+
stripAnchorBleedInPlace(item as Record<string, unknown>);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else if (value && typeof value === "object") {
|
|
333
|
+
stripAnchorBleedInPlace(value as Record<string, unknown>);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Extension Entry Point ────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export default function (pi: ExtensionAPI) {
|
|
341
|
+
const embeddedModels = modelsData as JsonModel[];
|
|
342
|
+
const customModels = customModelsData as JsonModel[];
|
|
343
|
+
const patches = patchData as PatchData;
|
|
344
|
+
|
|
345
|
+
const staleBase = loadStaleModels(embeddedModels);
|
|
346
|
+
const staleModels = buildModels(staleBase, customModels, patches);
|
|
347
|
+
|
|
348
|
+
pi.registerProvider("fireworks", {
|
|
349
|
+
baseUrl: BASE_URL,
|
|
350
|
+
apiKey: "$FIREWORKS_API_KEY",
|
|
351
|
+
api: "openai-completions",
|
|
352
|
+
models: staleModels,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
356
|
+
revalidateAbort?.abort();
|
|
357
|
+
revalidateAbort = new AbortController();
|
|
358
|
+
const signal = revalidateAbort.signal;
|
|
359
|
+
resolveApiKey(ctx.modelRegistry).then(() => {
|
|
360
|
+
revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
|
|
361
|
+
if (freshBase && !signal.aborted) {
|
|
362
|
+
pi.registerProvider("fireworks", {
|
|
363
|
+
baseUrl: BASE_URL,
|
|
364
|
+
apiKey: "$FIREWORKS_API_KEY",
|
|
365
|
+
api: "openai-completions",
|
|
366
|
+
models: buildModels(freshBase, customModels, patches),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
pi.on("session_shutdown", () => {
|
|
374
|
+
revalidateAbort?.abort();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Sanitize JSON Schema patterns for Kimi models before sending to Fireworks.
|
|
378
|
+
// Kimi K2.x has a known bug where regex anchors (^ and $) in pattern fields
|
|
379
|
+
// leak into generated argument strings, especially when alternation (|) is
|
|
380
|
+
// present. We strip anchors from simple patterns and drop patterns that
|
|
381
|
+
// combine alternation with anchors entirely.
|
|
382
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
383
|
+
if (!isFireworksKimiModel(ctx.model)) return;
|
|
384
|
+
|
|
385
|
+
const payload = event.payload as Record<string, unknown>;
|
|
386
|
+
if (!payload || typeof payload !== "object") return;
|
|
387
|
+
|
|
388
|
+
let modified = false;
|
|
389
|
+
|
|
390
|
+
const tools = payload.tools;
|
|
391
|
+
if (Array.isArray(tools)) {
|
|
392
|
+
payload.tools = tools.map((tool: any) => {
|
|
393
|
+
if (tool?.function?.parameters) {
|
|
394
|
+
return {
|
|
395
|
+
...tool,
|
|
396
|
+
function: {
|
|
397
|
+
...tool.function,
|
|
398
|
+
parameters: sanitizeSchemaForKimi(tool.function.parameters),
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return tool;
|
|
403
|
+
});
|
|
404
|
+
modified = true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const responseFormat = payload.response_format as any;
|
|
408
|
+
if (responseFormat?.json_schema?.schema) {
|
|
409
|
+
responseFormat.json_schema.schema = sanitizeSchemaForKimi(responseFormat.json_schema.schema);
|
|
410
|
+
modified = true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (modified) {
|
|
414
|
+
return payload;
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Defense-in-depth: if the model still generates a value with a leading ^ or
|
|
419
|
+
// trailing $ (anchor bleed), strip those characters from string tool arguments
|
|
420
|
+
// before they reach the tool / MCP server.
|
|
421
|
+
pi.on("tool_call", (event, ctx) => {
|
|
422
|
+
if (!isFireworksKimiModel(ctx.model)) return;
|
|
423
|
+
|
|
424
|
+
const input = (event as any).input;
|
|
425
|
+
if (input && typeof input === "object") {
|
|
426
|
+
stripAnchorBleedInPlace(input);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|