@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/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 === true || record.supportsDynamicTools === true ? { supportsDynamicTools: true } : {}
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: model.supportsDynamicTools ?? kimiCodeModelDef(model.id)?.supportsDynamicTools === true
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 value = message[MESSAGE_TOOLS];
12430
- if (!Array.isArray(value) || value.length === 0) return void 0;
12431
- return value;
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 system = leadingSystemText(options);
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 bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
12829
- const limit = JSON.stringify(body).includes("\"video_url\"") ? MAX_VIDEO_MESSAGE_BODY_BYTES : MAX_MESSAGE_BODY_BYTES;
12830
- if (bytes <= limit) return;
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) return {
13377
- code: matchesAny(detail, ACCOUNT_LIMIT_PATTERNS) || detail !== "" ? "PROVIDER_ERROR" : "PROVIDER_ERROR",
13378
- retryable: false,
13379
- message: `${PROVIDER_NAME} blocked the request on an account limit (403): ${detail || "the account limit was reached"}. The quota refreshes on its own schedule — check the Kimi Code card in Settings for the reset time.`
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 ?? false
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: entry?.supportsDynamicTools === true
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 built = buildRequest({
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,CA2ChG"}
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"}