@eddyskywalker/dsh-chatgpt-subscription 0.3.0 → 0.3.1
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 +26 -0
- package/lib/client.js +11 -5
- package/lib/client.js.map +1 -1
- package/lib/index.js +484 -22
- package/lib/types/client/kimi-code/KimiModelCapabilities.d.ts.map +1 -1
- package/lib/types/client/kimi-code/locales.d.ts +12 -6
- package/lib/types/client/kimi-code/locales.d.ts.map +1 -1
- package/lib/types/client/kimi-code/styles.d.ts.map +1 -1
- package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
- package/lib/types/host/kimi-code/client.d.ts +12 -0
- package/lib/types/host/kimi-code/client.d.ts.map +1 -1
- package/lib/types/host/kimi-code/mapper.d.ts +38 -2
- package/lib/types/host/kimi-code/mapper.d.ts.map +1 -1
- package/lib/types/host/kimi-code/video-store.d.ts +99 -0
- package/lib/types/host/kimi-code/video-store.d.ts.map +1 -0
- package/lib/types/host/kimi-code/video-tool.d.ts +25 -0
- package/lib/types/host/kimi-code/video-tool.d.ts.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -11332,7 +11332,7 @@ function parseCatalogModel(value) {
|
|
|
11332
11332
|
inputModalities: modalities,
|
|
11333
11333
|
protocol: protocol === "anthropic" ? "anthropic" : "openai",
|
|
11334
11334
|
...record.supports_video_in === true || record.supportsVideoIn === true ? { supportsVideo: true } : {},
|
|
11335
|
-
...record.supports_dynamic_tools
|
|
11335
|
+
...typeof (record.supports_dynamic_tools ?? record.supportsDynamicTools) === "boolean" ? { supportsDynamicTools: record.supports_dynamic_tools ?? record.supportsDynamicTools } : {}
|
|
11336
11336
|
};
|
|
11337
11337
|
}
|
|
11338
11338
|
/**
|
|
@@ -11385,6 +11385,22 @@ function reasoningEffortsForEntry(modelId, catalog) {
|
|
|
11385
11385
|
if (entry?.reasoningEfforts !== void 0) return [...entry.reasoningEfforts];
|
|
11386
11386
|
return reasoningEffortsFor(modelId);
|
|
11387
11387
|
}
|
|
11388
|
+
/**
|
|
11389
|
+
* Whether one model accepts message-level tool declarations.
|
|
11390
|
+
*
|
|
11391
|
+
* THE single resolution point for `dynamically_loaded_tools`: the settings card
|
|
11392
|
+
* and the request builder must never disagree, because the failure mode is a UI
|
|
11393
|
+
* that promises a capability the sent request silently drops. Precedence is the
|
|
11394
|
+
* live listing (including an explicit false), then the shipped registry.
|
|
11395
|
+
*
|
|
11396
|
+
* @param modelId - exact model id.
|
|
11397
|
+
* @param catalog - live catalog, possibly empty when offline.
|
|
11398
|
+
*/
|
|
11399
|
+
function dynamicToolsForEntry(modelId, catalog) {
|
|
11400
|
+
const entry = catalog.find((model) => model.id === modelId);
|
|
11401
|
+
if (entry?.supportsDynamicTools !== void 0) return entry.supportsDynamicTools;
|
|
11402
|
+
return kimiCodeModelDef(modelId)?.supportsDynamicTools === true;
|
|
11403
|
+
}
|
|
11388
11404
|
/** Input modalities for one model, from the catalog when it declares them. */
|
|
11389
11405
|
function inputModalitiesForEntry(modelId, catalog) {
|
|
11390
11406
|
const entry = catalog.find((model) => model.id === modelId);
|
|
@@ -11412,7 +11428,7 @@ function buildModelOptions(catalog, enabledModelIds, contextWindowOverrides) {
|
|
|
11412
11428
|
description: model.description ?? kimiCodeModelDef(model.id)?.description ?? null,
|
|
11413
11429
|
supportsVideo: model.supportsVideo ?? kimiCodeModelDef(model.id)?.inputModalities.includes("video") ?? false,
|
|
11414
11430
|
minimumPlan: model.minimumPlan ?? kimiCodeModelDef(model.id)?.minimumPlan ?? null,
|
|
11415
|
-
supportsDynamicTools:
|
|
11431
|
+
supportsDynamicTools: dynamicToolsForEntry(model.id, catalog)
|
|
11416
11432
|
};
|
|
11417
11433
|
});
|
|
11418
11434
|
}
|
|
@@ -12284,6 +12300,10 @@ async function resolveRequestVideos(options, attachments, signal) {
|
|
|
12284
12300
|
}));
|
|
12285
12301
|
return resolved;
|
|
12286
12302
|
}
|
|
12303
|
+
/** True when the request carries any video occurrence at all. */
|
|
12304
|
+
function requestHasVideo(options) {
|
|
12305
|
+
return options.messages.some((message) => Array.isArray(message.content) && message.content.some((block) => isRecord(block) && block.type === "video"));
|
|
12306
|
+
}
|
|
12287
12307
|
/**
|
|
12288
12308
|
* Resolve one video block for the wire, or explain why it cannot be sent.
|
|
12289
12309
|
* @param block - the durable video occurrence.
|
|
@@ -12389,6 +12409,7 @@ function leadingSystemText(options) {
|
|
|
12389
12409
|
if (typeof options.system === "string" && options.system.trim() !== "") parts.push(options.system);
|
|
12390
12410
|
for (const message of options.messages) {
|
|
12391
12411
|
if (message.role !== "system") continue;
|
|
12412
|
+
if (messageToolsOf(message) !== void 0) continue;
|
|
12392
12413
|
const text = textOf(message.content);
|
|
12393
12414
|
if (text !== "") parts.push(text);
|
|
12394
12415
|
}
|
|
@@ -12424,11 +12445,24 @@ function reasoningText(message) {
|
|
|
12424
12445
|
* honor; the property is invisible to them and only this mapper looks for it.
|
|
12425
12446
|
*/
|
|
12426
12447
|
const MESSAGE_TOOLS = Symbol.for("dsh-chatgpt-subscription.kimi-code.messageTools");
|
|
12448
|
+
/**
|
|
12449
|
+
* Serializable key the declaration travels under.
|
|
12450
|
+
*
|
|
12451
|
+
* A symbol alone is not enough: the session log persists messages through
|
|
12452
|
+
* JSON, which drops symbol-keyed properties, so a restored session would lose
|
|
12453
|
+
* every declaration and the model would believe it had tools the request no
|
|
12454
|
+
* longer carries. The declaration is therefore stored under a plain string key
|
|
12455
|
+
* AND the symbol, so in-process readers keep the exempt-from-`Object.keys`
|
|
12456
|
+
* behaviour while a round trip through persistence still reconstructs it.
|
|
12457
|
+
*/
|
|
12458
|
+
const MESSAGE_TOOLS_KEY = "kimiCodeMessageTools";
|
|
12427
12459
|
/** Message-level tool declarations one message carries, when any. */
|
|
12428
12460
|
function messageToolsOf(message) {
|
|
12429
|
-
const
|
|
12430
|
-
|
|
12431
|
-
|
|
12461
|
+
const record = message;
|
|
12462
|
+
for (const key of [MESSAGE_TOOLS, MESSAGE_TOOLS_KEY]) {
|
|
12463
|
+
const value = record[key];
|
|
12464
|
+
if (Array.isArray(value) && value.length > 0) return value;
|
|
12465
|
+
}
|
|
12432
12466
|
}
|
|
12433
12467
|
/** One declaration in the wire shape Kimi documents for `messages[].tools`. */
|
|
12434
12468
|
function openAIDynamicTool(tool) {
|
|
@@ -12526,6 +12560,7 @@ function estimatedInputTokens(options) {
|
|
|
12526
12560
|
}
|
|
12527
12561
|
/** Why one declaration could not be put on the wire, as the model sees it. */
|
|
12528
12562
|
function declarationNotice(model, count, reason) {
|
|
12563
|
+
if (reason === "wire") return `[${count} dynamically loaded tool(s) were not sent: model "${model}" is served over the Anthropic Messages protocol, which does not document message-level tool declarations. Do not call those tools.]`;
|
|
12529
12564
|
return reason === "capability" ? `[${count} dynamically loaded tool(s) were not sent: model "${model}" does not declare the dynamically_loaded_tools capability.]` : `[${count} dynamically loaded tool(s) were not sent: a tool declaration must be a content-less system message, and this one also carries text. Resend the declaration on its own system message.]`;
|
|
12530
12565
|
}
|
|
12531
12566
|
/**
|
|
@@ -12775,6 +12810,9 @@ function mergeAnthropicMessages(entries) {
|
|
|
12775
12810
|
}
|
|
12776
12811
|
/** Build one `/v1/messages` body. */
|
|
12777
12812
|
function buildAnthropicRequest(options, images = NO_RESOLVED_IMAGES, _media = {}) {
|
|
12813
|
+
const carriers = options.messages.filter((message) => message.role === "system" && messageToolsOf(message) !== void 0);
|
|
12814
|
+
const unsentDeclarations = carriers.reduce((total, message) => total + (messageToolsOf(message)?.length ?? 0), 0);
|
|
12815
|
+
const carrierText = carriers.map((message) => textOf(message.content)).filter((text) => text !== "");
|
|
12778
12816
|
const entries = [];
|
|
12779
12817
|
for (const message of nonSystemMessages(options)) {
|
|
12780
12818
|
if (isToolResultMessage(message)) {
|
|
@@ -12789,7 +12827,12 @@ function buildAnthropicRequest(options, images = NO_RESOLVED_IMAGES, _media = {}
|
|
|
12789
12827
|
content: message.role === "assistant" ? anthropicAssistantContent(message) : anthropicUserContent(message, images)
|
|
12790
12828
|
});
|
|
12791
12829
|
}
|
|
12792
|
-
const
|
|
12830
|
+
const systemParts = [
|
|
12831
|
+
leadingSystemText(options),
|
|
12832
|
+
...carrierText,
|
|
12833
|
+
...unsentDeclarations === 0 ? [] : [declarationNotice(options.model, unsentDeclarations, "wire")]
|
|
12834
|
+
].filter((part) => part !== void 0 && part !== "");
|
|
12835
|
+
const system = systemParts.length === 0 ? void 0 : systemParts.join("\n\n");
|
|
12793
12836
|
const maxTokens = options.maxTokens ?? maxOutputTokensFor(options.model);
|
|
12794
12837
|
const effort = mapReasoningEffort(options.reasoningEffort === void 0 ? void 0 : String(options.reasoningEffort));
|
|
12795
12838
|
const budget = thinkingBudgetFor(effort, maxTokens);
|
|
@@ -12823,11 +12866,15 @@ function buildRequest(options, wire, images = NO_RESOLVED_IMAGES, preserveThinki
|
|
|
12823
12866
|
* remedy (DSH's compaction), and a request that cannot succeed should not be
|
|
12824
12867
|
* sent at all. The measured size is the real serialized body, so it accounts
|
|
12825
12868
|
* for tool schemas and inlined images the caller cannot easily estimate.
|
|
12869
|
+
*
|
|
12870
|
+
* @returns the serialized body, so a caller that is about to send it does not
|
|
12871
|
+
* serialize a body of up to tens of megabytes a second time.
|
|
12826
12872
|
*/
|
|
12827
|
-
function assertRequestBodyFits(body) {
|
|
12828
|
-
const
|
|
12829
|
-
const
|
|
12830
|
-
|
|
12873
|
+
function assertRequestBodyFits(body, carriesVideo = false) {
|
|
12874
|
+
const serialized = JSON.stringify(body);
|
|
12875
|
+
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
12876
|
+
const limit = carriesVideo ? MAX_VIDEO_MESSAGE_BODY_BYTES : MAX_MESSAGE_BODY_BYTES;
|
|
12877
|
+
if (bytes <= limit) return serialized;
|
|
12831
12878
|
throw new LlmError(`Kimi Code rejected the request before sending: the serialized body is ${bytes} bytes, above the ${limit}-byte limit this route enforces. Compact the conversation or start a new session, and check for large tool results or attached media.`, "PROVIDER_ERROR");
|
|
12832
12879
|
}
|
|
12833
12880
|
function createStreamState(wire) {
|
|
@@ -13373,11 +13420,14 @@ function classifyKimiFailure(status, bodyText) {
|
|
|
13373
13420
|
retryable: false,
|
|
13374
13421
|
message: `${PROVIDER_NAME} refused this request for the current plan: ${detail || "the requested model or context is not included"}. Switch to a model the plan includes, lower the context-window override, or upgrade the subscription.`
|
|
13375
13422
|
};
|
|
13376
|
-
if (status === 403)
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13423
|
+
if (status === 403) {
|
|
13424
|
+
const limitReached = matchesAny(detail, ACCOUNT_LIMIT_PATTERNS);
|
|
13425
|
+
return {
|
|
13426
|
+
code: "PROVIDER_ERROR",
|
|
13427
|
+
retryable: false,
|
|
13428
|
+
message: `${PROVIDER_NAME} blocked the request on an account limit (403): ${detail || (limitReached ? "the account limit was reached" : "the account refused the request")}. The quota refreshes on its own schedule — check the Kimi Code card in Settings for the reset time.`
|
|
13429
|
+
};
|
|
13430
|
+
}
|
|
13381
13431
|
return {
|
|
13382
13432
|
code: "INVALID_CREDENTIAL",
|
|
13383
13433
|
retryable: false,
|
|
@@ -13453,7 +13503,7 @@ var KimiCodeAdapter = class extends LlmAdapter {
|
|
|
13453
13503
|
contextWindow: model.contextWindow,
|
|
13454
13504
|
inputModalities: [...kimiCodeModelDef(model.id)?.inputModalities ?? ["text"]],
|
|
13455
13505
|
supportsVideo: kimiCodeModelDef(model.id)?.inputModalities.includes("video") ?? false,
|
|
13456
|
-
supportsDynamicTools: kimiCodeModelDef(model.id)?.supportsDynamicTools
|
|
13506
|
+
supportsDynamicTools: kimiCodeModelDef(model.id)?.supportsDynamicTools === true
|
|
13457
13507
|
}));
|
|
13458
13508
|
}
|
|
13459
13509
|
contextWindowFor(modelId, entry, overrides) {
|
|
@@ -13531,17 +13581,15 @@ var KimiCodeAdapter = class extends LlmAdapter {
|
|
|
13531
13581
|
const media = {
|
|
13532
13582
|
videos,
|
|
13533
13583
|
videoAccepted: inputModalitiesForEntry(options.model, catalog).includes("video"),
|
|
13534
|
-
messageTools:
|
|
13584
|
+
messageTools: dynamicToolsForEntry(options.model, catalog)
|
|
13535
13585
|
};
|
|
13536
13586
|
const settings = await this.settings();
|
|
13537
13587
|
const contextWindow = this.contextWindowFor(options.model, entry, settings.contextWindowOverrides);
|
|
13538
13588
|
const requestedMax = options.maxTokens ?? maxOutputTokensFor(options.model, contextWindow);
|
|
13539
|
-
const
|
|
13589
|
+
const body = assertRequestBodyFits(buildRequest({
|
|
13540
13590
|
...requestOptions,
|
|
13541
13591
|
maxTokens: clampOutputToContext(requestedMax, contextWindow, estimatedInputTokens(requestOptions))
|
|
13542
|
-
}, wire, images, void 0, media);
|
|
13543
|
-
assertRequestBodyFits(built);
|
|
13544
|
-
const body = JSON.stringify(built);
|
|
13592
|
+
}, wire, images, void 0, media), requestHasVideo(requestOptions));
|
|
13545
13593
|
const region = credentials.region ?? await resolveRegion();
|
|
13546
13594
|
const base = (credentials.baseUrl ?? codingBaseUrl(region)).replace(/\/+$/, "");
|
|
13547
13595
|
const endpoint = wire === "anthropic" ? `${base}/v1/messages?beta=true` : `${base}/v1/chat/completions`;
|
|
@@ -13914,6 +13962,414 @@ function registerKimiCodeRoutes(ctx, store, modelSettings, preferences, options
|
|
|
13914
13962
|
});
|
|
13915
13963
|
}
|
|
13916
13964
|
//#endregion
|
|
13965
|
+
//#region src/host/kimi-code/video-store.ts
|
|
13966
|
+
/**
|
|
13967
|
+
* Local store for videos this plugin attaches to a Kimi Code request.
|
|
13968
|
+
*
|
|
13969
|
+
* DSH's attachment service normalizes and stores images only, and no DSH
|
|
13970
|
+
* surface can produce a video block, so the route owns its own store rather
|
|
13971
|
+
* than pretending DSH issued these references. The store is deliberately
|
|
13972
|
+
* narrow: a tool ingests bytes, a reader hands them back for one request.
|
|
13973
|
+
*
|
|
13974
|
+
* The identifier is the sha256 of the stored bytes, so re-ingesting the same
|
|
13975
|
+
* file is idempotent and a reference cannot silently point at different bytes.
|
|
13976
|
+
*/
|
|
13977
|
+
/**
|
|
13978
|
+
* Largest single video this route will carry.
|
|
13979
|
+
*
|
|
13980
|
+
* Two independent published limits bound this, and the smaller wins:
|
|
13981
|
+
*
|
|
13982
|
+
* - the official Kimi video integration encodes a local file as
|
|
13983
|
+
* `data:video/...;base64,...` and caps that payload at about 50 MB;
|
|
13984
|
+
* - the VS Code client caps a picked video file at 20 MB.
|
|
13985
|
+
*
|
|
13986
|
+
* The integration figure is the one that describes what the MODEL accepts
|
|
13987
|
+
* over this exact wire, so the cap is set just under it rather than at the
|
|
13988
|
+
* editor's more conservative picker limit. Note this is an encoded payload
|
|
13989
|
+
* limit: base64 grows bytes by 4/3, so the raw file must stay under 3/4 of it.
|
|
13990
|
+
*/
|
|
13991
|
+
const MAX_VIDEO_FILE_BYTES = 30 * 1024 * 1024;
|
|
13992
|
+
Math.ceil(MAX_VIDEO_FILE_BYTES / 3) * 4;
|
|
13993
|
+
/** Directory the ingested videos live in. */
|
|
13994
|
+
function videoStoreDir() {
|
|
13995
|
+
return path.join(dshHomeDir(), "storages", "kimi-code-videos");
|
|
13996
|
+
}
|
|
13997
|
+
/** A file extension for a media type, for the stored object's leaf name. */
|
|
13998
|
+
function extensionFor(mediaType) {
|
|
13999
|
+
return {
|
|
14000
|
+
"video/mp4": ".mp4",
|
|
14001
|
+
"video/webm": ".webm",
|
|
14002
|
+
"video/quicktime": ".mov",
|
|
14003
|
+
"video/x-msvideo": ".avi",
|
|
14004
|
+
"video/mpeg": ".mpeg",
|
|
14005
|
+
"video/mpg": ".mpg",
|
|
14006
|
+
"video/x-flv": ".flv",
|
|
14007
|
+
"video/x-ms-wmv": ".wmv",
|
|
14008
|
+
"video/3gpp": ".3gp"
|
|
14009
|
+
}[mediaType] ?? ".bin";
|
|
14010
|
+
}
|
|
14011
|
+
const EXTENSION_MEDIA_TYPES = {
|
|
14012
|
+
".mp4": "video/mp4",
|
|
14013
|
+
".m4v": "video/mp4",
|
|
14014
|
+
".webm": "video/webm",
|
|
14015
|
+
".mov": "video/quicktime",
|
|
14016
|
+
".avi": "video/x-msvideo",
|
|
14017
|
+
".mpeg": "video/mpeg",
|
|
14018
|
+
".mpg": "video/mpg",
|
|
14019
|
+
".flv": "video/x-flv",
|
|
14020
|
+
".wmv": "video/x-ms-wmv",
|
|
14021
|
+
".3gp": "video/3gpp"
|
|
14022
|
+
};
|
|
14023
|
+
/**
|
|
14024
|
+
* Media type for one path, from its extension.
|
|
14025
|
+
*
|
|
14026
|
+
* A local file rarely carries a usable declared type, so the extension is the
|
|
14027
|
+
* signal; an unknown one yields undefined rather than a guess, so the caller
|
|
14028
|
+
* can refuse instead of sending a type the service will reject.
|
|
14029
|
+
*/
|
|
14030
|
+
function mediaTypeForPath(filePath) {
|
|
14031
|
+
return EXTENSION_MEDIA_TYPES[path.extname(filePath).toLowerCase()];
|
|
14032
|
+
}
|
|
14033
|
+
/**
|
|
14034
|
+
* Whether this route will actually send this container.
|
|
14035
|
+
*
|
|
14036
|
+
* The ingress accepts exactly what the mapper can put on the wire, so a file
|
|
14037
|
+
* is either accepted and sent or refused with a reason. An earlier revision
|
|
14038
|
+
* also stored Matroska (.mkv), which some third-party integrations accept but
|
|
14039
|
+
* the vision guide does not list; that produced a video reported as "attached"
|
|
14040
|
+
* which the mapper then silently downgraded to a placeholder. Accepting only
|
|
14041
|
+
* {@link KIMI_VIDEO_MEDIA_TYPES} keeps the tool's promise and the wire's
|
|
14042
|
+
* behaviour the same thing.
|
|
14043
|
+
*/
|
|
14044
|
+
function isStorableVideoType(mediaType) {
|
|
14045
|
+
return KIMI_VIDEO_MEDIA_TYPES.includes(mediaType);
|
|
14046
|
+
}
|
|
14047
|
+
/** Raised when an ingest cannot produce a usable reference. */
|
|
14048
|
+
var VideoIngestError = class extends Error {
|
|
14049
|
+
code;
|
|
14050
|
+
constructor(message, code) {
|
|
14051
|
+
super(message);
|
|
14052
|
+
this.code = code;
|
|
14053
|
+
this.name = "VideoIngestError";
|
|
14054
|
+
}
|
|
14055
|
+
};
|
|
14056
|
+
/**
|
|
14057
|
+
* Commit one video's bytes and return the reference a request can cite.
|
|
14058
|
+
*
|
|
14059
|
+
* @param data - complete encoded file bytes.
|
|
14060
|
+
* @param declaredType - media type from the source (extension or Content-Type).
|
|
14061
|
+
* @param name - display name; never interpreted as a path.
|
|
14062
|
+
* @param declaredBytes - total the source advertised, when it did, so an
|
|
14063
|
+
* oversized file is refused before its bytes are buffered.
|
|
14064
|
+
*/
|
|
14065
|
+
async function saveVideo(input) {
|
|
14066
|
+
if (input.declaredBytes !== void 0 && input.declaredBytes > 31457280) throw new VideoIngestError(`video is ${input.declaredBytes} bytes, above the ${MAX_VIDEO_FILE_BYTES}-byte limit this route sends`, "VIDEO_TOO_LARGE");
|
|
14067
|
+
if (input.data.byteLength === 0) throw new VideoIngestError("video is empty", "VIDEO_EMPTY");
|
|
14068
|
+
if (input.data.byteLength > 31457280) throw new VideoIngestError(`video is ${input.data.byteLength} bytes, above the ${MAX_VIDEO_FILE_BYTES}-byte limit this route sends`, "VIDEO_TOO_LARGE");
|
|
14069
|
+
if (input.declaredType === void 0 || !isStorableVideoType(input.declaredType)) throw new VideoIngestError(`unsupported video type "${input.declaredType ?? "unknown"}"; supported: ${KIMI_VIDEO_MEDIA_TYPES.join(", ")}`, "VIDEO_TYPE_UNSUPPORTED");
|
|
14070
|
+
const digest = createHash("sha256").update(input.data).digest("hex");
|
|
14071
|
+
const attachmentId = `sha256:${digest}`;
|
|
14072
|
+
const dir = videoStoreDir();
|
|
14073
|
+
await fsPromises.mkdir(dir, {
|
|
14074
|
+
recursive: true,
|
|
14075
|
+
mode: 448
|
|
14076
|
+
});
|
|
14077
|
+
const target = path.join(dir, digest + extensionFor(input.declaredType));
|
|
14078
|
+
if (!await fsPromises.stat(target).then(() => true).catch(() => false)) {
|
|
14079
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
14080
|
+
await fsPromises.writeFile(tmp, input.data);
|
|
14081
|
+
await fsPromises.rename(tmp, target);
|
|
14082
|
+
}
|
|
14083
|
+
pruneVideoStore().catch(() => void 0);
|
|
14084
|
+
return {
|
|
14085
|
+
attachmentId,
|
|
14086
|
+
mediaType: input.declaredType,
|
|
14087
|
+
bytes: input.data.byteLength,
|
|
14088
|
+
name: input.name
|
|
14089
|
+
};
|
|
14090
|
+
}
|
|
14091
|
+
/**
|
|
14092
|
+
* Total bytes the store may occupy before it prunes itself.
|
|
14093
|
+
*
|
|
14094
|
+
* Videos are content-addressed and re-attaching an identical file costs nothing,
|
|
14095
|
+
* but nothing ever removed an old clip, so the directory only grew. The budget
|
|
14096
|
+
* is deliberately generous (a few large clips) and enforced only on write, so a
|
|
14097
|
+
* prune never runs during a read of the clip about to be sent.
|
|
14098
|
+
*/
|
|
14099
|
+
const VIDEO_STORE_BUDGET_BYTES = 512 * 1024 * 1024;
|
|
14100
|
+
/**
|
|
14101
|
+
* Drop the least recently used objects until the store fits its budget.
|
|
14102
|
+
*
|
|
14103
|
+
* Recency is the object's mtime, which writing refreshes, so a clip that was
|
|
14104
|
+
* just re-attached is not the one evicted. Failure is swallowed: a store that
|
|
14105
|
+
* cannot be pruned must not fail the attachment the caller asked for.
|
|
14106
|
+
*/
|
|
14107
|
+
async function pruneVideoStore(budgetBytes = VIDEO_STORE_BUDGET_BYTES) {
|
|
14108
|
+
const dir = videoStoreDir();
|
|
14109
|
+
let entries;
|
|
14110
|
+
try {
|
|
14111
|
+
const names = await fsPromises.readdir(dir);
|
|
14112
|
+
entries = [];
|
|
14113
|
+
for (const name of names) {
|
|
14114
|
+
if (name.includes(".tmp.")) {
|
|
14115
|
+
await fsPromises.rm(path.join(dir, name), { force: true }).catch(() => void 0);
|
|
14116
|
+
continue;
|
|
14117
|
+
}
|
|
14118
|
+
const stats = await fsPromises.stat(path.join(dir, name)).catch(() => void 0);
|
|
14119
|
+
if (stats === void 0 || !stats.isFile()) continue;
|
|
14120
|
+
entries.push({
|
|
14121
|
+
path: path.join(dir, name),
|
|
14122
|
+
size: stats.size,
|
|
14123
|
+
atime: stats.mtimeMs
|
|
14124
|
+
});
|
|
14125
|
+
}
|
|
14126
|
+
} catch {
|
|
14127
|
+
return 0;
|
|
14128
|
+
}
|
|
14129
|
+
let total = entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
14130
|
+
if (total <= budgetBytes) return 0;
|
|
14131
|
+
entries.sort((a, b) => a.atime - b.atime);
|
|
14132
|
+
let removed = 0;
|
|
14133
|
+
for (const entry of entries) {
|
|
14134
|
+
if (total <= budgetBytes) break;
|
|
14135
|
+
await fsPromises.rm(entry.path, { force: true }).catch(() => void 0);
|
|
14136
|
+
total -= entry.size;
|
|
14137
|
+
removed += 1;
|
|
14138
|
+
}
|
|
14139
|
+
return removed;
|
|
14140
|
+
}
|
|
14141
|
+
/** Path of the stored object for a reference, or undefined when it is gone. */
|
|
14142
|
+
function storedPathFor(ref) {
|
|
14143
|
+
const digest = ref.attachmentId.startsWith("sha256:") ? ref.attachmentId.slice(7) : void 0;
|
|
14144
|
+
if (digest === void 0 || !/^[0-9a-f]{64}$/.test(digest)) return void 0;
|
|
14145
|
+
return path.join(videoStoreDir(), digest + extensionFor(ref.mediaType));
|
|
14146
|
+
}
|
|
14147
|
+
/**
|
|
14148
|
+
* Read the bytes one reference points at.
|
|
14149
|
+
*
|
|
14150
|
+
* The reference is re-verified against the stored bytes: a file whose digest
|
|
14151
|
+
* no longer matches its name is refused rather than sent, so a tampered or
|
|
14152
|
+
* truncated object cannot reach the model as if it were the original.
|
|
14153
|
+
*/
|
|
14154
|
+
async function readVideoBytes(ref) {
|
|
14155
|
+
const stored = storedPathFor(ref);
|
|
14156
|
+
if (stored === void 0) throw new VideoIngestError("video reference is not one this store issued", "VIDEO_REFERENCE_INVALID");
|
|
14157
|
+
let data;
|
|
14158
|
+
try {
|
|
14159
|
+
data = await fsPromises.readFile(stored);
|
|
14160
|
+
} catch {
|
|
14161
|
+
throw new VideoIngestError("stored video is missing; attach it again", "VIDEO_MISSING");
|
|
14162
|
+
}
|
|
14163
|
+
if (`sha256:${createHash("sha256").update(data).digest("hex")}` !== ref.attachmentId) throw new VideoIngestError("stored video failed its integrity check", "VIDEO_CORRUPT");
|
|
14164
|
+
return data;
|
|
14165
|
+
}
|
|
14166
|
+
//#endregion
|
|
14167
|
+
//#region src/host/kimi-code/video-tool.ts
|
|
14168
|
+
/**
|
|
14169
|
+
* Tool that attaches a video for the next Kimi Code request.
|
|
14170
|
+
*
|
|
14171
|
+
* DSH's attachment service stores images only and no DSH surface can produce a
|
|
14172
|
+
* video block, so this route supplies its own ingress. The bytes are committed
|
|
14173
|
+
* to the plugin's video store and then injected into the conversation as a
|
|
14174
|
+
* plugin-sourced user message, which is the same mechanism this plugin already
|
|
14175
|
+
* uses to return a generated image to the model.
|
|
14176
|
+
*
|
|
14177
|
+
* Two sources are accepted:
|
|
14178
|
+
*
|
|
14179
|
+
* - an absolute local path, read straight from disk;
|
|
14180
|
+
* - an http(s) URL, fetched through the deployment's proxy-aware fetch and
|
|
14181
|
+
* checked against the same public-address policy the search/fetch provider
|
|
14182
|
+
* uses, so this cannot become an SSRF primitive against the local network.
|
|
14183
|
+
*/
|
|
14184
|
+
const KIMI_VIDEO_TOOL_NAME = "kimi_attach_video";
|
|
14185
|
+
/**
|
|
14186
|
+
* Provider route this tool attaches videos for.
|
|
14187
|
+
*
|
|
14188
|
+
* A video block is this plugin's own extension to DSH's content vocabulary, so
|
|
14189
|
+
* injecting one while a different provider serves the session would hand that
|
|
14190
|
+
* adapter a block it has no case for. The tool therefore refuses unless the
|
|
14191
|
+
* routed provider is this one, which is the same contract `read_image` states
|
|
14192
|
+
* for a modality the active model does not declare.
|
|
14193
|
+
*/
|
|
14194
|
+
const KIMI_CODE_PROVIDER = "kimi-code";
|
|
14195
|
+
/**
|
|
14196
|
+
* Refuse when the session's routed model cannot accept video.
|
|
14197
|
+
*
|
|
14198
|
+
* Mirrors `read_image`'s capability gate: the point is to fail here with a
|
|
14199
|
+
* remedy ("switch to k3") rather than send a block the route will reject or,
|
|
14200
|
+
* worse, silently drop.
|
|
14201
|
+
*/
|
|
14202
|
+
async function assertVideoCapableRoute(ctx, exec, source) {
|
|
14203
|
+
const agent = exec.agent;
|
|
14204
|
+
const routed = agent?.session?.requestHeader?.()?.config;
|
|
14205
|
+
const provider = routed?.provider ?? agent?.options?.provider;
|
|
14206
|
+
const model = routed?.model ?? agent?.options?.model;
|
|
14207
|
+
if (provider === void 0 || model === void 0) throw new HarnessError(`cannot attach "${source}": the current model route could not be resolved`, "KIMI_VIDEO_ROUTE_UNRESOLVED");
|
|
14208
|
+
if (provider !== KIMI_CODE_PROVIDER) throw new HarnessError(`cannot attach "${source}": video input is only wired for the ${KIMI_CODE_PROVIDER} provider, and this session is routed to "${provider}"`, "KIMI_VIDEO_WRONG_PROVIDER");
|
|
14209
|
+
const llm = ctx.get("llm");
|
|
14210
|
+
if (llm === void 0) throw new HarnessError(`cannot attach "${source}": the LLM service is unavailable`, "KIMI_VIDEO_NO_LLM");
|
|
14211
|
+
const active = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
14212
|
+
if (active.inputModalities === void 0 || !active.inputModalities.includes("video")) throw new HarnessError(`cannot attach "${source}": model "${model}" does not accept video input; switch to k3 or kimi-for-coding`, "KIMI_VIDEO_MODEL_UNSUPPORTED");
|
|
14213
|
+
}
|
|
14214
|
+
function humanBytes(bytes) {
|
|
14215
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
14216
|
+
}
|
|
14217
|
+
/** Read one local file, refusing anything that is not a regular file. */
|
|
14218
|
+
async function readLocalFile(input) {
|
|
14219
|
+
if (!path.isAbsolute(input)) throw new HarnessError(`video path must be absolute: ${input}`, "KIMI_VIDEO_PATH_NOT_ABSOLUTE");
|
|
14220
|
+
const stats = await fsPromises.stat(input).catch(() => void 0);
|
|
14221
|
+
if (stats === void 0) throw new HarnessError(`video file not found: ${input}`, "KIMI_VIDEO_NOT_FOUND");
|
|
14222
|
+
if (!stats.isFile()) throw new HarnessError(`video path is not a regular file: ${input}`, "KIMI_VIDEO_NOT_A_FILE");
|
|
14223
|
+
if (stats.size > 31457280) throw new HarnessError(`video is ${humanBytes(stats.size)}, above the ${humanBytes(MAX_VIDEO_FILE_BYTES)} limit this route sends`, "KIMI_VIDEO_TOO_LARGE");
|
|
14224
|
+
const type = mediaTypeForPath(input);
|
|
14225
|
+
if (type === void 0) throw new HarnessError(`unsupported video extension "${path.extname(input)}"; supported: .mp4 .m4v .webm .mov .avi .mpeg .mpg .flv .wmv .3gp`, "KIMI_VIDEO_TYPE_UNSUPPORTED");
|
|
14226
|
+
return {
|
|
14227
|
+
data: await fsPromises.readFile(input),
|
|
14228
|
+
name: path.basename(input),
|
|
14229
|
+
type
|
|
14230
|
+
};
|
|
14231
|
+
}
|
|
14232
|
+
/**
|
|
14233
|
+
* Fetch one http(s) video after proving the destination is public.
|
|
14234
|
+
*
|
|
14235
|
+
* The address policy is applied before the request, for the same reason the
|
|
14236
|
+
* search/fetch provider applies it: without it a URL argument would let a
|
|
14237
|
+
* caller reach loopback and private-range services through this tool.
|
|
14238
|
+
*/
|
|
14239
|
+
async function fetchRemoteVideo(url, fetchFn, signal) {
|
|
14240
|
+
let parsed;
|
|
14241
|
+
try {
|
|
14242
|
+
parsed = new URL(url);
|
|
14243
|
+
} catch {
|
|
14244
|
+
throw new HarnessError(`video URL is not a valid URL: ${url}`, "KIMI_VIDEO_URL_INVALID");
|
|
14245
|
+
}
|
|
14246
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new HarnessError(`video URL must be http or https, got "${parsed.protocol}"`, "KIMI_VIDEO_URL_INVALID");
|
|
14247
|
+
const addresses = await lookupHostAddresses(parsed.hostname).catch(() => []);
|
|
14248
|
+
assertPublicFetchTarget(parsed.hostname, addresses);
|
|
14249
|
+
const response = await fetchFn(url, {
|
|
14250
|
+
redirect: "error",
|
|
14251
|
+
signal
|
|
14252
|
+
});
|
|
14253
|
+
if (!response.ok) {
|
|
14254
|
+
await response.body?.cancel().catch(() => void 0);
|
|
14255
|
+
throw new HarnessError(`video download failed (${response.status})`, "KIMI_VIDEO_FETCH_FAILED");
|
|
14256
|
+
}
|
|
14257
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
14258
|
+
if (Number.isFinite(declared) && declared > 31457280) {
|
|
14259
|
+
await response.body?.cancel().catch(() => void 0);
|
|
14260
|
+
throw new HarnessError(`video is ${humanBytes(declared)}, above the ${humanBytes(MAX_VIDEO_FILE_BYTES)} limit this route sends`, "KIMI_VIDEO_TOO_LARGE");
|
|
14261
|
+
}
|
|
14262
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
14263
|
+
const headerType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim();
|
|
14264
|
+
const type = headerType !== void 0 && headerType.startsWith("video/") ? headerType : mediaTypeForPath(parsed.pathname);
|
|
14265
|
+
return {
|
|
14266
|
+
data,
|
|
14267
|
+
name: decodeURIComponent(parsed.pathname.split("/").pop() ?? "") || "video",
|
|
14268
|
+
type: type ?? ""
|
|
14269
|
+
};
|
|
14270
|
+
}
|
|
14271
|
+
function createKimiVideoTool(ctx, options = {}) {
|
|
14272
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
14273
|
+
return defineTool({
|
|
14274
|
+
name: KIMI_VIDEO_TOOL_NAME,
|
|
14275
|
+
description: "Attach a video to the conversation for models that accept video input (Kimi k3 and kimi-for-coding). Use this when the user asks about a video file or a video URL: pass an absolute local path or an http(s) URL. The video is sent to the model on the next request; it does not work on image-only models such as k3-256k.",
|
|
14276
|
+
parameters: {
|
|
14277
|
+
source: {
|
|
14278
|
+
type: "string",
|
|
14279
|
+
required: true,
|
|
14280
|
+
description: "Absolute local file path, or an http(s) URL, of the video to attach."
|
|
14281
|
+
},
|
|
14282
|
+
question: {
|
|
14283
|
+
type: "string",
|
|
14284
|
+
description: "Optional question to ask about the video, answered on the next turn."
|
|
14285
|
+
}
|
|
14286
|
+
},
|
|
14287
|
+
output: {
|
|
14288
|
+
schema: {
|
|
14289
|
+
type: "object",
|
|
14290
|
+
additionalProperties: false,
|
|
14291
|
+
properties: {
|
|
14292
|
+
source: {
|
|
14293
|
+
type: "string",
|
|
14294
|
+
required: true
|
|
14295
|
+
},
|
|
14296
|
+
mediaType: {
|
|
14297
|
+
type: "string",
|
|
14298
|
+
required: true
|
|
14299
|
+
},
|
|
14300
|
+
bytes: {
|
|
14301
|
+
type: "integer",
|
|
14302
|
+
required: true
|
|
14303
|
+
},
|
|
14304
|
+
attachmentId: {
|
|
14305
|
+
type: "string",
|
|
14306
|
+
required: true
|
|
14307
|
+
}
|
|
14308
|
+
}
|
|
14309
|
+
},
|
|
14310
|
+
render: (_args, value) => {
|
|
14311
|
+
const output = value;
|
|
14312
|
+
return [{
|
|
14313
|
+
type: "text",
|
|
14314
|
+
text: `Attached video ${output.source} (${output.mediaType}, ${humanBytes(output.bytes)}).`
|
|
14315
|
+
}];
|
|
14316
|
+
}
|
|
14317
|
+
},
|
|
14318
|
+
timeoutMs: 5 * 6e4,
|
|
14319
|
+
isConcurrencySafe: () => true,
|
|
14320
|
+
presentCall: (args) => ({
|
|
14321
|
+
card: "generic",
|
|
14322
|
+
kind: "other",
|
|
14323
|
+
title: "Attach video",
|
|
14324
|
+
rawInput: args
|
|
14325
|
+
}),
|
|
14326
|
+
presentResult: (_args, result) => ({
|
|
14327
|
+
card: "generic",
|
|
14328
|
+
title: result.isError ? "Video attach failed" : "Video attached",
|
|
14329
|
+
content: result.content
|
|
14330
|
+
}),
|
|
14331
|
+
async execute(args, exec) {
|
|
14332
|
+
const source = args.source.trim();
|
|
14333
|
+
if (source === "") throw new HarnessError("video source cannot be empty", "KIMI_VIDEO_SOURCE_EMPTY");
|
|
14334
|
+
await assertVideoCapableRoute(ctx, exec, source);
|
|
14335
|
+
const loaded = /^https?:\/\//i.test(source) ? await fetchRemoteVideo(source, fetchFn, exec.signal) : await readLocalFile(source);
|
|
14336
|
+
let ref;
|
|
14337
|
+
try {
|
|
14338
|
+
ref = await saveVideo({
|
|
14339
|
+
data: loaded.data,
|
|
14340
|
+
declaredType: loaded.type === "" ? void 0 : loaded.type,
|
|
14341
|
+
name: loaded.name
|
|
14342
|
+
});
|
|
14343
|
+
} catch (error) {
|
|
14344
|
+
if (error instanceof VideoIngestError) throw new HarnessError(error.message, `KIMI_${error.code}`);
|
|
14345
|
+
throw error;
|
|
14346
|
+
}
|
|
14347
|
+
const question = typeof args.question === "string" ? args.question.trim() : "";
|
|
14348
|
+
exec.deferContext(createUserMessage({
|
|
14349
|
+
content: [{
|
|
14350
|
+
type: "video",
|
|
14351
|
+
attachment: ref
|
|
14352
|
+
}, ...question === "" ? [] : [{
|
|
14353
|
+
type: "text",
|
|
14354
|
+
text: question
|
|
14355
|
+
}]],
|
|
14356
|
+
source: {
|
|
14357
|
+
kind: "plugin",
|
|
14358
|
+
plugin: "dsh-chatgpt-subscription",
|
|
14359
|
+
form: "notice",
|
|
14360
|
+
summary: `Attached video ${loaded.name} for the next request.`
|
|
14361
|
+
}
|
|
14362
|
+
}));
|
|
14363
|
+
return {
|
|
14364
|
+
source: loaded.name,
|
|
14365
|
+
mediaType: ref.mediaType,
|
|
14366
|
+
bytes: ref.bytes,
|
|
14367
|
+
attachmentId: ref.attachmentId
|
|
14368
|
+
};
|
|
14369
|
+
}
|
|
14370
|
+
});
|
|
14371
|
+
}
|
|
14372
|
+
//#endregion
|
|
13917
14373
|
//#region src/host/subagent-model-authorization.ts
|
|
13918
14374
|
/** DSH settings namespace owned by the Subagent settings card. */
|
|
13919
14375
|
const SUBAGENT_MODEL_SELECTION_NAMESPACE = "subagent-model-selection";
|
|
@@ -14583,7 +15039,11 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
14583
15039
|
let commandCodeConflict = null;
|
|
14584
15040
|
const kimiCodeAdapter = new KimiCodeAdapter(kimiCodeStore, kimiCodeModelSettings, kimiCodePreferences, {
|
|
14585
15041
|
fetchFn: proxyFetch,
|
|
14586
|
-
attachments: ctx.attachments
|
|
15042
|
+
attachments: ctx.attachments,
|
|
15043
|
+
videos: { readVideo: async (ref) => ({
|
|
15044
|
+
data: await readVideoBytes(ref),
|
|
15045
|
+
mediaType: ref.mediaType
|
|
15046
|
+
}) }
|
|
14587
15047
|
});
|
|
14588
15048
|
let kimiCodeRegistration;
|
|
14589
15049
|
let kimiCodeConflict = null;
|
|
@@ -14645,6 +15105,7 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
14645
15105
|
const disposeRoutes = registerRoutes(ctx, oauth, usage, preferences, proxyManager, searchSwitcher);
|
|
14646
15106
|
const disposeAdapter = ctx.llm.registerAdapter([PROVIDER_ID$3], adapter);
|
|
14647
15107
|
const disposeImageTool = ctx.tools.register(createCodexImageTool(oauth, ctx.attachments, { fetchFn: proxyFetch }));
|
|
15108
|
+
const disposeVideoTool = ctx.tools.register(createKimiVideoTool(ctx, { fetchFn: proxyFetch }));
|
|
14648
15109
|
ctx.inject(["web"], (ctx) => {
|
|
14649
15110
|
ctx.web.registerSearchProvider(createCodexSearchProvider(oauth, { fetchFn: proxyFetch }));
|
|
14650
15111
|
ctx.web.registerFetchProvider(createCodexFetchProvider({ fetchFn: proxyFetch }));
|
|
@@ -14661,6 +15122,7 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
14661
15122
|
disposeProxyWatch();
|
|
14662
15123
|
disposePreferenceWatch();
|
|
14663
15124
|
disposeImageTool();
|
|
15125
|
+
disposeVideoTool();
|
|
14664
15126
|
disposeAdapter();
|
|
14665
15127
|
disposeRoutes();
|
|
14666
15128
|
disposeAntigravityRoutes();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"KimiModelCapabilities.d.ts","sourceRoot":"","sources":["../../../../src/client/kimi-code/KimiModelCapabilities.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAA;AAK9E,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,mBAAmB,EAAE,CAAA;CAC9B;AAED,wBAAgB,qBAAqB,CAAC,EAAE,MAAM,EAAE,EAAE,0BAA0B,GAAG,KAAK,CAAC,YAAY,
|
|
1
|
+
{"version":3,"file":"KimiModelCapabilities.d.ts","sourceRoot":"","sources":["../../../../src/client/kimi-code/KimiModelCapabilities.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qCAAqC,CAAA;AAK9E,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,mBAAmB,EAAE,CAAA;CAC9B;AAED,wBAAgB,qBAAqB,CAAC,EAAE,MAAM,EAAE,EAAE,0BAA0B,GAAG,KAAK,CAAC,YAAY,CA8ChG"}
|