@nylorun/runtime 0.24.0-beta → 0.26.0-beta

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.
Files changed (171) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +18 -4
  3. package/dist/api/a2a/card.d.ts +11 -1
  4. package/dist/api/a2a/card.js +26 -2
  5. package/dist/api/a2a/endpoint.js +4 -2
  6. package/dist/api/a2a/protocol.d.ts +25 -2
  7. package/dist/api/a2a/protocol.js +20 -3
  8. package/dist/api/a2a/routes.d.ts +4 -1
  9. package/dist/api/a2a/routes.js +65 -7
  10. package/dist/api/ag-ui/endpoint.js +5 -2
  11. package/dist/api/ag-ui/history.d.ts +6 -1
  12. package/dist/api/ag-ui/history.js +35 -2
  13. package/dist/api/ag-ui/routes.js +53 -11
  14. package/dist/api/components.d.ts +1165 -678
  15. package/dist/api/components.js +16 -1
  16. package/dist/api/gate/keys.d.ts +2 -1
  17. package/dist/api/gate/keys.js +7 -0
  18. package/dist/api/gate/routes.js +13 -0
  19. package/dist/api/http/app.d.ts +2 -2
  20. package/dist/api/http/app.js +15 -4
  21. package/dist/api/http/body.d.ts +3 -3
  22. package/dist/api/http/body.js +7 -7
  23. package/dist/api/http/define.d.ts +1 -1
  24. package/dist/api/http/respond.js +1 -1
  25. package/dist/api/http/routes/environment-manifests.d.ts +9 -0
  26. package/dist/api/http/routes/environment-manifests.js +118 -0
  27. package/dist/api/http/routes/environments.js +61 -12
  28. package/dist/api/http/routes/reads.d.ts +52 -3
  29. package/dist/api/http/routes/sessions.js +14 -4
  30. package/dist/api/http/routes/tenant.js +35 -7
  31. package/dist/api/inbound-files.d.ts +65 -0
  32. package/dist/api/inbound-files.js +115 -0
  33. package/dist/api/openapi.js +18 -3
  34. package/dist/artifacts/disk.js +20 -14
  35. package/dist/artifacts/media-types.d.ts +15 -0
  36. package/dist/artifacts/media-types.js +56 -4
  37. package/dist/artifacts/parts.d.ts +12 -3
  38. package/dist/artifacts/parts.js +15 -4
  39. package/dist/artifacts/workspace.d.ts +4 -4
  40. package/dist/artifacts/workspace.js +6 -18
  41. package/dist/core/flow-host.d.ts +1 -1
  42. package/dist/core/flow-host.js +2 -2
  43. package/dist/core/sandbox-routes.js +2 -2
  44. package/dist/core/turn-manifest.d.ts +3 -2
  45. package/dist/core/turn-manifest.js +29 -5
  46. package/dist/gates/egress-credentials.js +1 -1
  47. package/dist/gates/egress.d.ts +13 -19
  48. package/dist/gates/egress.js +32 -29
  49. package/dist/gates/http-tool.d.ts +3 -4
  50. package/dist/gates/http-tool.js +23 -19
  51. package/dist/gates/journal.js +0 -1
  52. package/dist/gates/mcp-handler.js +2 -3
  53. package/dist/gates/policy.js +1 -3
  54. package/dist/gates/tool-contract.d.ts +1 -1
  55. package/dist/harness/calls.d.ts +2 -2
  56. package/dist/harness/calls.js +9 -12
  57. package/dist/harness/main.js +7 -1
  58. package/dist/harness/pod.d.ts +6 -1
  59. package/dist/harness/pod.js +5 -0
  60. package/dist/harness/service.js +5 -5
  61. package/dist/harness/setup.d.ts +2 -0
  62. package/dist/harness/setup.js +25 -1
  63. package/dist/harness-api/record.d.ts +1 -1
  64. package/dist/harness-api/record.js +13 -13
  65. package/dist/harness-api/server.d.ts +27 -4
  66. package/dist/harness-api/server.js +41 -8
  67. package/dist/harness-api/tool-journal.d.ts +17 -8
  68. package/dist/harness-api/tool-journal.js +41 -11
  69. package/dist/harness-api/workspace.d.ts +7 -5
  70. package/dist/harness-api/workspace.js +25 -19
  71. package/dist/host/gates.js +2 -2
  72. package/dist/host/stack-config.js +1 -1
  73. package/dist/keys/contract.d.ts +1 -1
  74. package/dist/keys/contract.js +1 -1
  75. package/dist/keys/keys.d.ts +9 -2
  76. package/dist/keys/keys.js +4 -1
  77. package/dist/management-openapi.json +366 -61
  78. package/dist/mcp/connect.d.ts +1 -2
  79. package/dist/mcp/connect.js +1 -4
  80. package/dist/mcp/pool.d.ts +1 -4
  81. package/dist/mcp/pool.js +13 -27
  82. package/dist/mcp/preview.d.ts +1 -1
  83. package/dist/mcp/preview.js +1 -2
  84. package/dist/mcp/snapshot.d.ts +11 -13
  85. package/dist/mcp/snapshot.js +27 -38
  86. package/dist/model/capabilities.d.ts +26 -0
  87. package/dist/model/capabilities.js +44 -0
  88. package/dist/model/catalog.d.ts +34 -3
  89. package/dist/model/catalog.js +38 -2
  90. package/dist/model/models.d.ts +2 -0
  91. package/dist/model/models.js +1 -1
  92. package/dist/model/native-blocks.d.ts +35 -0
  93. package/dist/model/native-blocks.js +170 -0
  94. package/dist/model/pi-model.js +106 -94
  95. package/dist/openapi.json +9554 -5076
  96. package/dist/record/environment.js +1 -1
  97. package/dist/sandbox/join.js +4 -0
  98. package/dist/sandbox/manager.d.ts +32 -14
  99. package/dist/sandbox/manager.js +132 -93
  100. package/dist/sandbox/placement.d.ts +1 -1
  101. package/dist/sandbox/placement.js +2 -2
  102. package/dist/sandbox/pods/client.d.ts +3 -1
  103. package/dist/sandbox/pods/lifecycle.d.ts +8 -0
  104. package/dist/sandbox/pods/lifecycle.js +15 -0
  105. package/dist/sandbox/pods/spec.js +2 -1
  106. package/dist/sandbox/resolve.d.ts +5 -3
  107. package/dist/sandbox/resolve.js +9 -2
  108. package/dist/sandbox/runtime-tools.d.ts +9 -0
  109. package/dist/sandbox/runtime-tools.js +16 -0
  110. package/dist/sandbox/session-sandbox.d.ts +4 -6
  111. package/dist/sandbox/session-sandbox.js +47 -94
  112. package/dist/sandbox/setup.d.ts +4 -1
  113. package/dist/sandbox/setup.js +46 -9
  114. package/dist/sandbox/share.d.ts +12 -7
  115. package/dist/sandbox/share.js +27 -25
  116. package/dist/sandbox/skills.d.ts +8 -6
  117. package/dist/sandbox/skills.js +24 -23
  118. package/dist/store/postgres/drizzle/0024_flat_manifest.sql +7 -0
  119. package/dist/store/postgres/drizzle/0025_environment_manifests.sql +14 -0
  120. package/dist/store/postgres/drizzle/0026_environment_forks.sql +1 -0
  121. package/dist/store/postgres/drizzle/0027_environment_image_digest.sql +1 -0
  122. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  123. package/dist/store/postgres/migrate.js +1 -1
  124. package/dist/store/postgres/reads.js +8 -2
  125. package/dist/store/postgres/schema.d.ts +217 -0
  126. package/dist/store/postgres/schema.js +36 -2
  127. package/dist/store/postgres/store.js +79 -4
  128. package/dist/store/types.d.ts +68 -14
  129. package/dist/tenant/advance.js +3 -0
  130. package/dist/tenant/artifact-tool.d.ts +1 -1
  131. package/dist/tenant/artifact-tool.js +43 -22
  132. package/dist/tenant/commands.js +24 -4
  133. package/dist/tenant/context.d.ts +10 -4
  134. package/dist/tenant/effects.js +7 -9
  135. package/dist/tenant/environment-manifests.d.ts +57 -0
  136. package/dist/tenant/environment-manifests.js +200 -0
  137. package/dist/tenant/environments.d.ts +54 -47
  138. package/dist/tenant/environments.js +309 -85
  139. package/dist/tenant/ephemeral.d.ts +1 -1
  140. package/dist/tenant/host-token.js +1 -1
  141. package/dist/tenant/identity-file.d.ts +1 -1
  142. package/dist/tenant/identity-file.js +1 -1
  143. package/dist/tenant/model-setting.d.ts +14 -0
  144. package/dist/tenant/model-setting.js +51 -0
  145. package/dist/tenant/outbound.d.ts +6 -5
  146. package/dist/tenant/outbound.js +8 -6
  147. package/dist/tenant/sessions.d.ts +5 -1
  148. package/dist/tenant/sessions.js +45 -12
  149. package/dist/tenant/skill-tool.d.ts +3 -3
  150. package/dist/tenant/skill-tool.js +6 -11
  151. package/dist/tenant/sweep.js +1 -1
  152. package/dist/tenant/tool-results.js +1 -1
  153. package/dist/tenant/tool-search.d.ts +7 -11
  154. package/dist/tenant/tool-search.js +8 -5
  155. package/dist/tenant/transcript.d.ts +51 -32
  156. package/dist/tenant/transcript.js +2 -1
  157. package/dist/tenant/types.d.ts +2 -2
  158. package/dist/traces/ids.d.ts +0 -1
  159. package/dist/traces/ids.js +0 -4
  160. package/dist/traces/map.d.ts +2 -2
  161. package/dist/traces/map.js +1 -29
  162. package/dist/vault/coverage.js +11 -13
  163. package/dist/vault/host-model.d.ts +4 -1
  164. package/dist/vault/host-model.js +10 -2
  165. package/dist/vault/service.d.ts +49 -4
  166. package/dist/vault/service.js +153 -6
  167. package/dist/version.d.ts +1 -1
  168. package/dist/version.js +1 -1
  169. package/package.json +3 -3
  170. package/dist/sandbox/capability.d.ts +0 -3
  171. package/dist/sandbox/capability.js +0 -10
@@ -8,6 +8,8 @@ export type Selection = Readonly<{
8
8
  contextWindow?: number;
9
9
  maxTokens?: number;
10
10
  reasoning?: boolean;
11
+ /** What the endpoint reads (protocol 12); text only unless it says so. */
12
+ inputs?: readonly string[];
11
13
  compat?: Readonly<Record<string, unknown>>;
12
14
  }>;
13
15
  }>;
@@ -25,7 +25,7 @@ export function modelsFor(selection, credentials, options = {}) {
25
25
  provider: "custom",
26
26
  baseUrl: selection.custom.baseUrl,
27
27
  reasoning: selection.custom.reasoning ?? false,
28
- input: ["text", "image"],
28
+ input: selection.custom.inputs?.includes("image") ? ["text", "image"] : ["text"],
29
29
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
30
30
  contextWindow: selection.custom.contextWindow ?? CUSTOM_CONTEXT_WINDOW,
31
31
  maxTokens: selection.custom.maxTokens ?? CUSTOM_MAX_TOKENS,
@@ -0,0 +1,35 @@
1
+ import type { TextContent } from "@earendil-works/pi-ai";
2
+ /** The kinds of file a native block carries; images are pi-ai's own. */
3
+ export type NativeFileKind = "pdf" | "office" | "audio" | "video";
4
+ /** A file for one model call: its kind, media type, name and base64 bytes. */
5
+ export interface NativeFile {
6
+ readonly kind: NativeFileKind;
7
+ readonly mediaType: string;
8
+ readonly name: string;
9
+ /** The bytes, base64-encoded. */
10
+ readonly data: string;
11
+ }
12
+ /**
13
+ * The kinds each pi-ai API has a native block for here: the model inputs an operator may add
14
+ * (`NATIVE_INPUTS`), images aside, so the two tables never disagree.
15
+ */
16
+ export declare const NATIVE_FILE_KINDS: Readonly<Record<string, readonly NativeFileKind[]>>;
17
+ /** The kinds of file `api` carries in a native block; none for an API without a rewriter. */
18
+ export declare function nativeKindsOf(api: string): readonly NativeFileKind[];
19
+ export declare class NativeBlocks {
20
+ private readonly files;
21
+ /** Why the last `rewrite` could not carry every file; undefined when it did. */
22
+ error?: string;
23
+ /** How many files this call carries. */
24
+ get size(): number;
25
+ /** A text part standing for `file` in pi-ai's context, replaced by `rewrite`. */
26
+ add(file: NativeFile): TextContent;
27
+ /**
28
+ * `payload` (an `onPayload` request body for `api`) with each marker block replaced by its
29
+ * native block. Rewrites in place and returns the same payload; sets `error` when a file
30
+ * could not be carried.
31
+ */
32
+ rewrite(payload: unknown, api: string): unknown;
33
+ /** The native block for a marker block of ours; undefined for any other block. */
34
+ private replace;
35
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Native file blocks (multimodal messages, M4): a PDF, an office document, audio or video the
3
+ * model reads, carried to the provider in its own block. pi-ai's messages hold only text and
4
+ * images, so a file goes in as a marker text part (`add`), which pi-ai never merges or escapes,
5
+ * and the provider payload pi-ai builds is rewritten before it is sent (`rewrite`, from the
6
+ * call's `onPayload`): each marker block becomes the API's native block. Images never come
7
+ * here: pi-ai carries them itself.
8
+ *
9
+ * One instance serves one model call. pi-ai builds the payload again on every attempt, so
10
+ * `rewrite` can run more than once; each run sets `error` afresh. A file whose kind or format
11
+ * the API has no block for, an API with no rewriter, or a marker left in the payload (pi-ai
12
+ * folded it into a longer string, or dropped it) is `error`: the call fails with it rather than
13
+ * sending a marker to the model.
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import { essence } from "../artifacts/media-types.js";
17
+ import { NATIVE_INPUTS } from "./capabilities.js";
18
+ /**
19
+ * The kinds each pi-ai API has a native block for here: the model inputs an operator may add
20
+ * (`NATIVE_INPUTS`), images aside, so the two tables never disagree.
21
+ */
22
+ export const NATIVE_FILE_KINDS = Object.freeze(Object.fromEntries(Object.entries(NATIVE_INPUTS).map(([api, inputs]) => [
23
+ api,
24
+ Object.freeze(inputs.filter((input) => input !== "text" && input !== "image")),
25
+ ])));
26
+ /** The kinds of file `api` carries in a native block; none for an API without a rewriter. */
27
+ export function nativeKindsOf(api) {
28
+ return Object.hasOwn(NATIVE_FILE_KINDS, api) ? NATIVE_FILE_KINDS[api] : [];
29
+ }
30
+ const MARKER = /^\[\[nylorun-file:([0-9a-f-]{36})\]\]$/;
31
+ const MARKER_ANYWHERE = /\[\[nylorun-file:[0-9a-f-]{36}\]\]/;
32
+ const dataUrl = (file) => `data:${essence(file.mediaType)};base64,${file.data}`;
33
+ /** `cache_control` the marker block carried (pi-ai marks the last block of a request). */
34
+ const cacheOf = (block) => block.cache_control === undefined ? {} : { cache_control: block.cache_control };
35
+ const textOfType = (type) => (block) => block.type === type ? block.text : undefined;
36
+ /** `input_audio.format` for a media type: OpenAI Chat Completions takes WAV and MP3 only. */
37
+ function audioFormat(mediaType) {
38
+ switch (essence(mediaType)) {
39
+ case "audio/wav":
40
+ case "audio/x-wav":
41
+ case "audio/wave":
42
+ return "wav";
43
+ case "audio/mpeg":
44
+ case "audio/mp3":
45
+ return "mp3";
46
+ default:
47
+ return undefined;
48
+ }
49
+ }
50
+ const anthropic = (file, block) => ({
51
+ type: "document",
52
+ source: { type: "base64", media_type: essence(file.mediaType), data: file.data },
53
+ title: file.name,
54
+ ...cacheOf(block),
55
+ });
56
+ const responses = (file) => ({
57
+ type: "input_file",
58
+ filename: file.name,
59
+ file_data: dataUrl(file),
60
+ });
61
+ const completions = (file, block) => {
62
+ switch (file.kind) {
63
+ case "pdf":
64
+ return { type: "file", file: { filename: file.name, file_data: dataUrl(file) }, ...cacheOf(block) };
65
+ case "audio": {
66
+ const format = audioFormat(file.mediaType);
67
+ if (!format)
68
+ return "this API reads WAV and MP3 audio only";
69
+ return { type: "input_audio", input_audio: { data: file.data, format }, ...cacheOf(block) };
70
+ }
71
+ default:
72
+ return { type: "video_url", video_url: { url: dataUrl(file) }, ...cacheOf(block) };
73
+ }
74
+ };
75
+ const google = (file) => ({
76
+ inlineData: { mimeType: essence(file.mediaType), data: file.data },
77
+ });
78
+ const REWRITERS = {
79
+ "anthropic-messages": { marker: textOfType("text"), native: anthropic },
80
+ "openai-responses": { marker: textOfType("input_text"), native: responses },
81
+ "azure-openai-responses": { marker: textOfType("input_text"), native: responses },
82
+ "openai-completions": { marker: textOfType("text"), native: completions },
83
+ "google-generative-ai": { marker: (block) => (block.type === undefined ? block.text : undefined), native: google },
84
+ "google-vertex": { marker: (block) => (block.type === undefined ? block.text : undefined), native: google },
85
+ };
86
+ const isBlock = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
87
+ export class NativeBlocks {
88
+ files = new Map();
89
+ /** Why the last `rewrite` could not carry every file; undefined when it did. */
90
+ error;
91
+ /** How many files this call carries. */
92
+ get size() {
93
+ return this.files.size;
94
+ }
95
+ /** A text part standing for `file` in pi-ai's context, replaced by `rewrite`. */
96
+ add(file) {
97
+ const id = randomUUID();
98
+ this.files.set(id, file);
99
+ return { type: "text", text: `[[nylorun-file:${id}]]` };
100
+ }
101
+ /**
102
+ * `payload` (an `onPayload` request body for `api`) with each marker block replaced by its
103
+ * native block. Rewrites in place and returns the same payload; sets `error` when a file
104
+ * could not be carried.
105
+ */
106
+ rewrite(payload, api) {
107
+ this.error = undefined;
108
+ if (this.files.size === 0)
109
+ return payload;
110
+ const rewriter = Object.hasOwn(REWRITERS, api) ? REWRITERS[api] : undefined;
111
+ if (!rewriter) {
112
+ this.error = `The model's API (${api}) cannot carry files other than images`;
113
+ return payload;
114
+ }
115
+ const kinds = nativeKindsOf(api);
116
+ const placed = new Set();
117
+ let leftover = false;
118
+ const visit = (value) => {
119
+ if (Array.isArray(value)) {
120
+ for (let index = 0; index < value.length; index++) {
121
+ const item = value[index];
122
+ const replaced = isBlock(item) ? this.replace(item, rewriter, kinds, api) : undefined;
123
+ if (replaced) {
124
+ value[index] = replaced.block;
125
+ placed.add(replaced.id);
126
+ }
127
+ else
128
+ visit(item);
129
+ }
130
+ }
131
+ else if (isBlock(value)) {
132
+ for (const item of Object.values(value))
133
+ visit(item);
134
+ }
135
+ else if (typeof value === "string" && MARKER_ANYWHERE.test(value)) {
136
+ leftover = true;
137
+ }
138
+ };
139
+ visit(payload);
140
+ if (this.error)
141
+ return payload;
142
+ if (leftover || placed.size < this.files.size) {
143
+ const missing = [...this.files].find(([id]) => !placed.has(id))?.[1];
144
+ this.error = missing
145
+ ? `File "${missing.name}" did not reach the ${api} request as its own block`
146
+ : `A file marker was left in the ${api} request`;
147
+ }
148
+ return payload;
149
+ }
150
+ /** The native block for a marker block of ours; undefined for any other block. */
151
+ replace(block, rewriter, kinds, api) {
152
+ const text = rewriter.marker(block);
153
+ if (typeof text !== "string")
154
+ return undefined;
155
+ const id = MARKER.exec(text)?.[1];
156
+ const file = id === undefined ? undefined : this.files.get(id);
157
+ if (!file)
158
+ return undefined;
159
+ const refuse = (reason) => {
160
+ this.error ??= `File "${file.name}" (${essence(file.mediaType)}) cannot be sent to ${api}: ${reason}`;
161
+ return undefined;
162
+ };
163
+ if (!kinds.includes(file.kind))
164
+ return refuse(`this API has no block for ${file.kind} files`);
165
+ const native = rewriter.native(file, block);
166
+ if (typeof native === "string")
167
+ return refuse(native);
168
+ return { id: id, block: native };
169
+ }
170
+ }
@@ -1,9 +1,12 @@
1
1
  import { parseJsonWithRepair, retryAssistantCall, } from "@earendil-works/pi-ai";
2
+ import { mediaReferenceOf } from "@nylorun/core/define";
2
3
  import { FileUnavailableError, } from "../artifacts/files.js";
3
- import { essence, isModelImage, isText } from "../artifacts/media-types.js";
4
+ import { essence, inputLabel, isModelImage, isText, modelInputOf, unreadableReason, } from "../artifacts/media-types.js";
4
5
  import { scrub } from "../redact.js";
6
+ import { capabilitiesOf } from "./capabilities.js";
5
7
  import { classifyAssistantError, failure } from "./classify.js";
6
8
  import { modelsFor } from "./models.js";
9
+ import { NativeBlocks, nativeKindsOf } from "./native-blocks.js";
7
10
  import { projectSecrets } from "./settings.js";
8
11
  const OPENAI_COMPATIBLE_REASONING = /^reasoning(_content|_text)?$/;
9
12
  const emptyUsage = () => ({
@@ -23,8 +26,6 @@ const noAmbientAuth = {
23
26
  return false;
24
27
  },
25
28
  };
26
- class InvalidRequest extends Error {
27
- }
28
29
  /**
29
30
  * Node model adapter. Local provider configuration is read only when invoked.
30
31
  * A provider failure is returned as a failure outcome; only an abort is thrown.
@@ -44,7 +45,7 @@ export function piModel(options = {}) {
44
45
  const root = options.root ?? "";
45
46
  const stored = await options.readHostModel?.();
46
47
  if (!stored)
47
- return failure("auth", "Model provider is not configured. Set it in Studio, or run nylo configure (npx @nylorun/cli configure).", false);
48
+ return failure("auth", "Model provider is not configured. Set it in Studio, or run nylorun tenant models set.", false);
48
49
  const secrets = [
49
50
  ...projectSecrets(root),
50
51
  ...credentialSecrets(stored.credential),
@@ -64,18 +65,22 @@ export function piModel(options = {}) {
64
65
  : {}),
65
66
  };
66
67
  const registry = modelsFor(selection, hostCredentialStore(stored, options.writeHostCredential), { environment: false, authContext: noAmbientAuth });
67
- const selected = registry.getModel(selection.provider, selection.model);
68
- if (!selected)
69
- return failure("invalid_request", "Unknown model. Run nylo configure (npx @nylorun/cli configure).", false);
70
- let request;
71
- try {
72
- request = await buildContext(call, selected, options);
73
- }
74
- catch (error) {
75
- if (error instanceof InvalidRequest)
76
- return failure("invalid_request", error.message, false);
77
- throw error;
78
- }
68
+ const found = registry.getModel(selection.provider, selection.model);
69
+ if (!found)
70
+ return failure("invalid_request", "Unknown model. Run nylorun tenant models set.", false);
71
+ // What the model reads (protocol 12): its catalog's inputs and the operator's additions.
72
+ // pi-ai drops an image the model's `input` lacks, so the call's model carries an added one.
73
+ const { inputs } = capabilitiesOf(found, selection.custom
74
+ ? stored.settings?.inputs
75
+ : stored.addedInputs?.[`${selection.provider}/${selection.model}`]);
76
+ const selected = {
77
+ ...found,
78
+ input: inputs.includes("image") ? ["text", "image"] : ["text"],
79
+ };
80
+ // PDFs, Office documents, audio and video go in the API's own blocks: pi-ai builds the
81
+ // request with a marker in each one's place and `onPayload` swaps it for the bytes.
82
+ const natives = new NativeBlocks();
83
+ const request = await buildContext(call, selected, inputs, options, natives);
79
84
  // Publish only portable references, never the materialized provider image bytes.
80
85
  context.reportPreparedCall?.({
81
86
  adapter: "runtime.pi-ai",
@@ -93,7 +98,20 @@ export function piModel(options = {}) {
93
98
  timeoutMs: settings.requestTimeoutMs,
94
99
  maxRetries: 2,
95
100
  maxRetryDelayMs: 60_000,
101
+ ...(natives.size > 0
102
+ ? {
103
+ // A file that cannot be carried stops the request before it is sent.
104
+ onPayload: (payload) => {
105
+ const rewritten = natives.rewrite(payload, selected.api);
106
+ if (natives.error)
107
+ throw new Error(natives.error);
108
+ return rewritten;
109
+ },
110
+ }
111
+ : {}),
96
112
  };
113
+ // A file the request could not carry fails the call at once: no message, so never retried.
114
+ const nativeFailure = () => failedMessage(selected);
97
115
  const attempt = async () => {
98
116
  const idle = new AbortController();
99
117
  const signal = AbortSignal.any([context.signal, idle.signal]);
@@ -126,6 +144,8 @@ export function piModel(options = {}) {
126
144
  }
127
145
  }
128
146
  const response = await stream.result();
147
+ if (natives.error)
148
+ return nativeFailure();
129
149
  if (idle.signal.aborted && !context.signal.aborted)
130
150
  return idleTimeout(selected, settings.idleTimeoutMs);
131
151
  return response;
@@ -133,6 +153,8 @@ export function piModel(options = {}) {
133
153
  catch (error) {
134
154
  if (context.signal.aborted)
135
155
  throw error;
156
+ if (natives.error)
157
+ return nativeFailure();
136
158
  if (idle.signal.aborted)
137
159
  return idleTimeout(selected, settings.idleTimeoutMs);
138
160
  return {
@@ -151,6 +173,8 @@ export function piModel(options = {}) {
151
173
  }, context.signal);
152
174
  // An abort is never a failure outcome: the host decides what it means.
153
175
  context.signal.throwIfAborted();
176
+ if (natives.error)
177
+ return redacted(failure("invalid_request", natives.error, false));
154
178
  if (response.stopReason === "aborted")
155
179
  return redacted(failure("transient", "The provider stream was aborted.", true));
156
180
  if (response.stopReason === "error") {
@@ -158,7 +182,7 @@ export function piModel(options = {}) {
158
182
  return redacted(outcome.code === "auth"
159
183
  ? {
160
184
  ...outcome,
161
- message: `${outcome.message} Update the provider credential in Studio's Model Settings, or run nylo configure.`,
185
+ message: `${outcome.message} Update the provider credential in Studio's Model Settings, or run nylorun tenant models set.`,
162
186
  }
163
187
  : outcome);
164
188
  }
@@ -231,87 +255,74 @@ export function piModel(options = {}) {
231
255
  };
232
256
  };
233
257
  }
234
- async function buildContext(call, selected, options) {
258
+ async function buildContext(call, selected, inputs, options, natives) {
235
259
  const messages = [];
236
260
  const instructions = [];
237
- const content = async (parts, toolResult = false) => {
261
+ /**
262
+ * A file a media part names (protocol 6, 12), as the model gets it: a text file as a `<file>`
263
+ * block, an image the model reads as an image, a PDF, Office document, audio or video the model
264
+ * reads as a marker for its native block (`natives`), and anything else, or a file that cannot
265
+ * be read, as a note. Its bytes go only into the provider request built here, never into the
266
+ * transcript or the journal, and no file fails the call here.
267
+ */
268
+ const materialize = async (part) => {
269
+ let name = mediaReferenceOf(part.reference)?.name ?? "file";
270
+ let type = essence(part.mediaType);
271
+ const note = (why) => ({
272
+ type: "text",
273
+ text: `(File ${JSON.stringify(name)}, ${type}, is not shown: ${why}.)`,
274
+ });
275
+ const refused = unreadableReason(part.mediaType, inputs);
276
+ if (refused)
277
+ return note(refused);
278
+ if (!options.files)
279
+ return note("files are unavailable");
280
+ let file;
281
+ try {
282
+ file = await options.files(part.reference);
283
+ }
284
+ catch (error) {
285
+ if (!(error instanceof FileUnavailableError))
286
+ throw error;
287
+ return note(error.message.replace(/\.$/u, ""));
288
+ }
289
+ name = file.name;
290
+ type = essence(file.mediaType);
291
+ const stored = unreadableReason(file.mediaType, inputs);
292
+ if (stored)
293
+ return note(stored);
294
+ if (isModelImage(file.mediaType))
295
+ return { type: "image", data: Buffer.from(file.bytes).toString("base64"), mimeType: type };
296
+ if (isText(file.mediaType)) {
297
+ let text;
298
+ try {
299
+ text = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
300
+ }
301
+ catch {
302
+ return note("it is not UTF-8 text");
303
+ }
304
+ return {
305
+ type: "text",
306
+ text: `<file name=${JSON.stringify(file.name)} type=${JSON.stringify(type)}>\n${text}\n</file>`,
307
+ };
308
+ }
309
+ const kind = modelInputOf(file.mediaType);
310
+ if (isNativeKind(kind) && nativeKindsOf(selected.api).includes(kind))
311
+ return natives.add({
312
+ kind,
313
+ mediaType: type,
314
+ name: file.name,
315
+ data: Buffer.from(file.bytes).toString("base64"),
316
+ });
317
+ return note(`the Runtime does not send ${inputLabel(kind)} to this model's API (${selected.api})`);
318
+ };
319
+ const content = async (parts) => {
238
320
  const result = [];
239
321
  for (const part of parts) {
240
322
  if (part.type === "text")
241
323
  result.push({ type: "text", text: part.text });
242
- else if (part.type === "media" && toolResult) {
243
- // An image a tool returned, stored as an artifact (R2b C11): shown to a model that
244
- // reads images. Otherwise, or when it cannot be read, a note: the call still goes.
245
- const note = (why) => ({
246
- type: "text",
247
- text: `(The tool returned an image, ${essence(part.mediaType)}, that is not shown: ${why}.)`,
248
- });
249
- if (!selected.input.includes("image") || !isModelImage(part.mediaType)) {
250
- result.push(note(selected.input.includes("image")
251
- ? "the model reads PNG, JPEG, GIF and WebP images only"
252
- : "this model does not accept images"));
253
- continue;
254
- }
255
- if (!options.files) {
256
- result.push(note("files are unavailable"));
257
- continue;
258
- }
259
- try {
260
- const file = await options.files(part.reference);
261
- if (!isModelImage(file.mediaType))
262
- result.push(note(`it is ${essence(file.mediaType)}`));
263
- else
264
- result.push({
265
- type: "image",
266
- data: Buffer.from(file.bytes).toString("base64"),
267
- mimeType: essence(file.mediaType),
268
- });
269
- }
270
- catch (error) {
271
- if (!(error instanceof FileUnavailableError))
272
- throw error;
273
- result.push(note(error.message.replace(/\.$/u, "")));
274
- }
275
- }
276
- else if (part.type === "media") {
277
- // A file the message named by artifact (protocol 6): its bytes go only into the
278
- // provider request built here, never into the transcript or the journal.
279
- if (!options.files)
280
- throw new InvalidRequest("Files are unavailable; pass a file resolver to piModel({ files }).");
281
- let file;
282
- try {
283
- file = await options.files(part.reference);
284
- }
285
- catch (error) {
286
- if (error instanceof FileUnavailableError)
287
- throw new InvalidRequest(error.message);
288
- throw error;
289
- }
290
- if (isModelImage(file.mediaType)) {
291
- if (!selected.input.includes("image"))
292
- throw new InvalidRequest("The configured model does not accept images.");
293
- result.push({
294
- type: "image",
295
- data: Buffer.from(file.bytes).toString("base64"),
296
- mimeType: essence(file.mediaType),
297
- });
298
- }
299
- else if (isText(file.mediaType)) {
300
- let text;
301
- try {
302
- text = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
303
- }
304
- catch {
305
- throw new InvalidRequest(`${file.name} is not UTF-8 text.`);
306
- }
307
- result.push({
308
- type: "text",
309
- text: `<file name=${JSON.stringify(file.name)} type=${JSON.stringify(essence(file.mediaType))}>\n${text}\n</file>`,
310
- });
311
- }
312
- else
313
- throw new InvalidRequest(`The model reads images and text files; ${file.name} is ${essence(file.mediaType)}.`);
314
- }
324
+ else if (part.type === "media")
325
+ result.push(await materialize(part));
315
326
  }
316
327
  return result;
317
328
  };
@@ -326,7 +337,7 @@ async function buildContext(call, selected, options) {
326
337
  toolName: item.toolName,
327
338
  isError: item.status !== "completed",
328
339
  timestamp: Date.now(),
329
- content: await content(item.content, true),
340
+ content: await content(item.content),
330
341
  });
331
342
  }
332
343
  else if (item.kind === "message" && item.role === "assistant") {
@@ -357,6 +368,7 @@ async function buildContext(call, selected, options) {
357
368
  tools,
358
369
  };
359
370
  }
371
+ const isNativeKind = (input) => input === "pdf" || input === "office" || input === "audio" || input === "video";
360
372
  /**
361
373
  * A replayed assistant message keeps the model that produced it, so pi-ai converts
362
374
  * history correctly after a model switch (tool-call ids, foreign reasoning). Messages
@@ -501,7 +513,7 @@ function hostCredentialStore(stored, write) {
501
513
  return next;
502
514
  },
503
515
  async delete() {
504
- throw new Error("Replace the model provider in Studio, or with nylo configure.");
516
+ throw new Error("Replace the model provider in Studio, or with nylorun tenant models set.");
505
517
  },
506
518
  };
507
519
  }