@sanity/ailf 2.4.0 → 2.5.0

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.
@@ -15,8 +15,18 @@
15
15
  import { existsSync } from "fs";
16
16
  import { resolve } from "path";
17
17
  import { PipelineRequestSchema, } from "../../_vendor/ailf-core/index.js";
18
+ import { LEGACY_EVAL_MODE_ALIASES } from "../../_vendor/ailf-shared/index.js";
18
19
  import { LiteracyVariant } from "../../pipeline/normalize-mode.js";
19
20
  import { RepoTaskSource } from "../task-sources/repo-task-source.js";
21
+ const LEGACY_LITERACY_VARIANT_SET = new Set(LEGACY_EVAL_MODE_ALIASES);
22
+ /**
23
+ * Resolve a raw `config.mode` (which may be a CLI literacy variant such as
24
+ * `"baseline"` or `"full"`) to the canonical task-level mode that appears on
25
+ * `GeneralizedTaskDefinition.mode`. Literacy variants all map to `"literacy"`.
26
+ */
27
+ function resolveCanonicalTaskMode(configMode) {
28
+ return LEGACY_LITERACY_VARIANT_SET.has(configMode) ? "literacy" : configMode;
29
+ }
20
30
  // ---------------------------------------------------------------------------
21
31
  // Public API
22
32
  // ---------------------------------------------------------------------------
@@ -33,10 +43,18 @@ import { RepoTaskSource } from "../task-sources/repo-task-source.js";
33
43
  */
34
44
  export async function buildRemoteRequest(options) {
35
45
  const { tasksDir, config } = options;
36
- // 1. Load and validate local tasks
46
+ // 1. Load and validate local tasks, filtered to the requested mode.
47
+ // `config.mode` may be a literacy variant (baseline/agentic/full/observed)
48
+ // — those all map to task mode "literacy". Other modes match 1:1.
37
49
  const taskSource = new RepoTaskSource(tasksDir);
38
50
  const filterOptions = buildFilterOptions(config);
39
- const tasks = (await taskSource.loadTasks(filterOptions)).filter((t) => t.mode === "literacy");
51
+ const allTasks = await taskSource.loadTasks(filterOptions);
52
+ const taskModeFilter = config.mode
53
+ ? resolveCanonicalTaskMode(config.mode)
54
+ : undefined;
55
+ const tasks = taskModeFilter
56
+ ? allTasks.filter((t) => t.mode === taskModeFilter)
57
+ : allTasks;
40
58
  if (tasks.length === 0) {
41
59
  throw new Error("No tasks found after applying filters.\n" +
42
60
  ` Tasks directory: ${tasksDir}\n` +
@@ -145,12 +163,13 @@ export function resolveTasksDir(rootDir, explicitPath) {
145
163
  // Helpers
146
164
  // ---------------------------------------------------------------------------
147
165
  /**
148
- * Convert a LiteracyTaskDefinition to the camelCase inline format expected
166
+ * Convert a GeneralizedTaskDefinition to the camelCase inline format expected
149
167
  * by the API.
150
168
  */
151
169
  function taskToInlineFormat(task) {
152
170
  const inline = {
153
171
  id: task.id,
172
+ mode: task.mode,
154
173
  description: task.title,
155
174
  featureArea: task.area ?? "",
156
175
  assert: task.assertions ?? [],
@@ -166,14 +185,17 @@ function taskToInlineFormat(task) {
166
185
  ...(task.prompt?.vars ?? {}),
167
186
  };
168
187
  }
169
- if (task.docCoverage) {
170
- inline.docCoverage = true;
171
- }
172
- if (task.referenceSolution) {
173
- inline.referenceSolution = task.referenceSolution;
174
- }
175
- if (task.baseline) {
176
- inline.baseline = task.baseline;
188
+ // Literacy-specific fields
189
+ if (task.mode === "literacy") {
190
+ if (task.docCoverage) {
191
+ inline.docCoverage = true;
192
+ }
193
+ if (task.referenceSolution) {
194
+ inline.referenceSolution = task.referenceSolution;
195
+ }
196
+ if (task.baseline) {
197
+ inline.baseline = task.baseline;
198
+ }
177
199
  }
178
200
  if (task.tags?.length) {
179
201
  inline.tags = task.tags;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ApiGatewayArtifactUploader — uploads report artifacts via the API Gateway.
3
+ *
4
+ * Counterpart to GcsReportArtifactUploader. Used when the CLI runs locally
5
+ * without GCS credentials. Two-step flow:
6
+ *
7
+ * 1. GET {apiBaseUrl}/v1/artifacts/{reportId}/upload-url?type={artifactType}
8
+ * with Authorization: Bearer {apiKey} — returns a signed PUT URL.
9
+ * 2. PUT the JSON to that URL with Content-Type: application/json and
10
+ * x-goog-if-generation-match: 0 (overwrite-protection contract from
11
+ * the gateway's signed URL).
12
+ *
13
+ * The gateway stays out of the data path — Vercel only signs the URL,
14
+ * the artifact bytes go directly to GCS.
15
+ *
16
+ * Design principles:
17
+ * - P5: Non-blocking — any failure returns null and warns, never throws.
18
+ * - Stateless — no client to keep around between calls.
19
+ *
20
+ * @see docs/design-docs/external-artifact-store.md
21
+ * @see docs/decisions/D0030-external-artifact-store.md
22
+ */
23
+ import type { ArtifactRef, ArtifactUploader } from "../_vendor/ailf-core/index.d.ts";
24
+ export interface ApiGatewayUploaderOptions {
25
+ /** Base URL of the API gateway (e.g., "https://api.ailf.sanity.io"). */
26
+ apiBaseUrl: string;
27
+ /** AILF API key with the `artifact:write` scope. */
28
+ apiKey: string;
29
+ /** GCS bucket name — included in the returned ArtifactRef. */
30
+ bucket: string;
31
+ }
32
+ export declare class ApiGatewayArtifactUploader implements ArtifactUploader {
33
+ private readonly options;
34
+ constructor(options: ApiGatewayUploaderOptions);
35
+ upload(reportId: string, fileName: string, data: unknown): Promise<ArtifactRef | null>;
36
+ /**
37
+ * Fetch a signed upload URL from the gateway. Returns null on any non-2xx
38
+ * response or malformed body so the caller can stay non-blocking.
39
+ */
40
+ private fetchSignedUrl;
41
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * ApiGatewayArtifactUploader — uploads report artifacts via the API Gateway.
3
+ *
4
+ * Counterpart to GcsReportArtifactUploader. Used when the CLI runs locally
5
+ * without GCS credentials. Two-step flow:
6
+ *
7
+ * 1. GET {apiBaseUrl}/v1/artifacts/{reportId}/upload-url?type={artifactType}
8
+ * with Authorization: Bearer {apiKey} — returns a signed PUT URL.
9
+ * 2. PUT the JSON to that URL with Content-Type: application/json and
10
+ * x-goog-if-generation-match: 0 (overwrite-protection contract from
11
+ * the gateway's signed URL).
12
+ *
13
+ * The gateway stays out of the data path — Vercel only signs the URL,
14
+ * the artifact bytes go directly to GCS.
15
+ *
16
+ * Design principles:
17
+ * - P5: Non-blocking — any failure returns null and warns, never throws.
18
+ * - Stateless — no client to keep around between calls.
19
+ *
20
+ * @see docs/design-docs/external-artifact-store.md
21
+ * @see docs/decisions/D0030-external-artifact-store.md
22
+ */
23
+ // ---------------------------------------------------------------------------
24
+ // File-name → artifact-type mapping (mirrors packages/api ARTIFACT_FILES)
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Reverse map of the API gateway's ARTIFACT_FILES. The uploader port speaks
28
+ * file names; the gateway endpoint speaks artifact types. Keep these in sync
29
+ * with packages/api/src/routes/artifacts.ts.
30
+ */
31
+ const FILE_TO_TYPE = {
32
+ "eval-results.json": "evalResults",
33
+ "grader-prompts.json": "graderPrompts",
34
+ "rendered-prompts.json": "renderedPrompts",
35
+ "task-definitions.json": "taskDefinitions",
36
+ "test-outputs.json": "testOutputs",
37
+ };
38
+ export class ApiGatewayArtifactUploader {
39
+ options;
40
+ constructor(options) {
41
+ this.options = options;
42
+ }
43
+ async upload(reportId, fileName, data) {
44
+ const artifactType = FILE_TO_TYPE[fileName];
45
+ if (!artifactType) {
46
+ console.warn(` ⚠️ Artifact upload skipped (unknown fileName): ${fileName}`);
47
+ return null;
48
+ }
49
+ const objectPath = `reports/${reportId}/${fileName}`;
50
+ const json = JSON.stringify(data);
51
+ const bytes = Buffer.byteLength(json, "utf-8");
52
+ try {
53
+ const signed = await this.fetchSignedUrl(reportId, artifactType);
54
+ if (!signed)
55
+ return null;
56
+ const putRes = await fetch(signed.url, {
57
+ body: json,
58
+ headers: signed.requiredHeaders,
59
+ method: "PUT",
60
+ });
61
+ if (!putRes.ok) {
62
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${objectPath} — GCS PUT ${putRes.status} ${putRes.statusText}`);
63
+ return null;
64
+ }
65
+ return {
66
+ bucket: signed.bucket,
67
+ bytes,
68
+ entryCount: extractEntryCount(data),
69
+ path: signed.path,
70
+ store: "gcs",
71
+ };
72
+ }
73
+ catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${objectPath} — ${message}`);
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Fetch a signed upload URL from the gateway. Returns null on any non-2xx
81
+ * response or malformed body so the caller can stay non-blocking.
82
+ */
83
+ async fetchSignedUrl(reportId, artifactType) {
84
+ const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}/v1/artifacts/${encodeURIComponent(reportId)}/upload-url?type=${encodeURIComponent(artifactType)}`;
85
+ const res = await fetch(url, {
86
+ headers: {
87
+ Authorization: `Bearer ${this.options.apiKey}`,
88
+ },
89
+ method: "GET",
90
+ });
91
+ if (!res.ok) {
92
+ console.warn(` ⚠️ Signed-URL request failed: ${res.status} ${res.statusText}`);
93
+ return null;
94
+ }
95
+ const body = (await res.json());
96
+ if (body.object !== "signed_upload_url" ||
97
+ typeof body.url !== "string" ||
98
+ typeof body.path !== "string" ||
99
+ typeof body.bucket !== "string" ||
100
+ !body.requiredHeaders) {
101
+ console.warn(` ⚠️ Signed-URL response was malformed`);
102
+ return null;
103
+ }
104
+ return {
105
+ bucket: body.bucket,
106
+ method: "PUT",
107
+ object: "signed_upload_url",
108
+ path: body.path,
109
+ requiredHeaders: body.requiredHeaders,
110
+ url: body.url,
111
+ };
112
+ }
113
+ }
114
+ function extractEntryCount(data) {
115
+ if (typeof data === "object" &&
116
+ data !== null &&
117
+ "entries" in data &&
118
+ typeof data.entries === "object") {
119
+ return Object.keys(data.entries)
120
+ .length;
121
+ }
122
+ return undefined;
123
+ }
@@ -15,7 +15,7 @@
15
15
  * @see packages/core/src/ports/context.ts — AppContext interface
16
16
  * @see docs/archive/exec-plans/ports-and-adapters/phase-7-composition-root.md
17
17
  */
18
- import { type AppContext, type AssertionRegistration, type ResolvedConfig } from "./_vendor/ailf-core/index.d.ts";
18
+ import { type AppContext, type ArtifactUploader, type AssertionRegistration, type Logger, type ResolvedConfig } from "./_vendor/ailf-core/index.d.ts";
19
19
  /**
20
20
  * Create a fully wired AppContext from resolved configuration.
21
21
  *
@@ -23,6 +23,15 @@ import { type AppContext, type AssertionRegistration, type ResolvedConfig } from
23
23
  * Swapping an adapter is a one-line change in this function.
24
24
  */
25
25
  export declare function createAppContext(config: ResolvedConfig): AppContext;
26
+ /**
27
+ * Selects an ArtifactUploader implementation based on available credentials.
28
+ *
29
+ * Returns undefined when artifact upload is not configured — the publish
30
+ * step skips silently in that case (P5).
31
+ *
32
+ * Exported for unit-test access; not part of the public package API.
33
+ */
34
+ export declare function createArtifactUploader(config: ResolvedConfig, logger: Logger): ArtifactUploader | undefined;
26
35
  /**
27
36
  * Generic Promptfoo assertion types available to all evaluation modes.
28
37
  *
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { join } from "node:path";
19
19
  import { InMemoryPluginRegistry, NoOpArtifactCollector, } from "./_vendor/ailf-core/index.js";
20
+ import { ApiGatewayArtifactUploader } from "./artifact-capture/api-gateway-artifact-uploader.js";
20
21
  import { FilesystemArtifactCollector } from "./artifact-capture/filesystem-collector.js";
21
22
  import { GcsArtifactCollector } from "./artifact-capture/gcs-collector.js";
22
23
  import { GcsReportArtifactUploader } from "./artifact-capture/gcs-report-artifact-uploader.js";
@@ -82,10 +83,14 @@ export function createAppContext(config) {
82
83
  : fsCollector;
83
84
  }
84
85
  // Report artifact uploader — uploads structured files to GCS at known
85
- // paths for Studio to fetch via signed URLs (D0030)
86
- const artifactUploader = config.artifactGcsBucket
87
- ? new GcsReportArtifactUploader({ bucket: config.artifactGcsBucket })
88
- : undefined;
86
+ // paths for Studio to fetch via signed URLs (D0030).
87
+ //
88
+ // Selection (W0042):
89
+ // 1. Direct GCS upload when ADC env vars are present (CI / GCP runtime)
90
+ // 2. API Gateway signed-URL upload when only an AILF API key is present
91
+ // (local dev — no GCS credentials needed)
92
+ // 3. Skipped silently when neither is configured
93
+ const artifactUploader = createArtifactUploader(config, logger);
89
94
  return {
90
95
  artifactUploader,
91
96
  cache,
@@ -115,6 +120,36 @@ function createLogger() {
115
120
  process.env.AILF_VERBOSE === "1",
116
121
  });
117
122
  }
123
+ /**
124
+ * Selects an ArtifactUploader implementation based on available credentials.
125
+ *
126
+ * Returns undefined when artifact upload is not configured — the publish
127
+ * step skips silently in that case (P5).
128
+ *
129
+ * Exported for unit-test access; not part of the public package API.
130
+ */
131
+ export function createArtifactUploader(config, logger) {
132
+ if (!config.artifactGcsBucket)
133
+ return undefined;
134
+ // CI / GCP runtime — direct GCS upload (fastest, no extra hop).
135
+ // We treat the presence of either env var as the user opting in to ADC.
136
+ const hasGcsCredentials = Boolean(process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GCLOUD_PROJECT);
137
+ if (hasGcsCredentials) {
138
+ logger.debug("Artifact uploader: GcsReportArtifactUploader (direct GCS via ADC)");
139
+ return new GcsReportArtifactUploader({ bucket: config.artifactGcsBucket });
140
+ }
141
+ // Local dev — request signed PUT URLs from the API gateway, no GCS creds needed.
142
+ if (config.apiKey && config.apiUrl) {
143
+ logger.debug(`Artifact uploader: ApiGatewayArtifactUploader (signed URL via ${config.apiUrl})`);
144
+ return new ApiGatewayArtifactUploader({
145
+ apiBaseUrl: config.apiUrl,
146
+ apiKey: config.apiKey,
147
+ bucket: config.artifactGcsBucket,
148
+ });
149
+ }
150
+ logger.debug("Artifact upload skipped: AILF_GCS_ARTIFACT_BUCKET set but no GCS credentials or AILF_API_KEY available");
151
+ return undefined;
152
+ }
118
153
  function createCache(config) {
119
154
  const local = new FilesystemCache(config.rootDir);
120
155
  if (config.noRemoteCache)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ailf",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"