+ ${renderAiNotice(this.aiNotice)}
diff --git a/web/apps/desk/src/services/TechAdminService.ts b/web/apps/desk/src/services/TechAdminService.ts
index accd585da..3da39f2e1 100644
--- a/web/apps/desk/src/services/TechAdminService.ts
+++ b/web/apps/desk/src/services/TechAdminService.ts
@@ -38,11 +38,15 @@ export interface AISettings {
anthropic_configured: boolean;
anthropic_key_hint: string | null;
vault_present: boolean;
+ /** The last test of the saved key; a save runs the test. */
+ test_status: 'none' | 'untested' | 'passed' | 'refused' | 'failed';
+ test_message?: string;
}
export interface AITestResult {
ok: boolean;
message: string;
+ status?: 'passed' | 'refused' | 'failed';
}
export interface RoutingSettings {
diff --git a/web/apps/desk/src/services/parsing.service.ts b/web/apps/desk/src/services/parsing.service.ts
index 6ca015f1f..43e42667a 100644
--- a/web/apps/desk/src/services/parsing.service.ts
+++ b/web/apps/desk/src/services/parsing.service.ts
@@ -3,6 +3,7 @@
import type { ParseResponse } from '../types/parsing';
import { fetchWithAuth } from './fetchClient';
+import { parseApiError } from './apiError';
const API_URL = import.meta.env.VITE_API_URL || '';
@@ -24,18 +25,9 @@ export const ParsingService = {
timeout: AI_PARSE_TIMEOUT,
});
- if (!response.ok) {
- const errorText = await response.text();
- // The wire error envelope carries the words a person reads.
- let words = '';
- try {
- const body = JSON.parse(errorText) as { error?: { message?: string } };
- words = body.error?.message ?? '';
- } catch {
- words = errorText;
- }
- throw new Error(words || 'Failed to parse material list');
- }
+ // The wire error envelope carries the words a person reads and the
+ // code a caller branches on (ai_refused, unsupported_type).
+ if (!response.ok) throw await parseApiError(response, 'Failed to parse material list');
return response.json();
},
diff --git a/web/apps/desk/src/test/e2eNoNetwork.test.ts b/web/apps/desk/src/test/e2eNoNetwork.test.ts
new file mode 100644
index 000000000..d13e6c994
--- /dev/null
+++ b/web/apps/desk/src/test/e2eNoNetwork.test.ts
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0
+// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors
+
+/**
+ * No end to end spec sends a request to a third party address (item 130,
+ * round 1): a base URL or link a spec types must be loopback or a reserved
+ * example domain, so CI never reaches a provider on the network.
+ */
+import { describe, it, expect } from 'vitest'
+import { readdirSync, readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+
+// The desk workspace is the working directory of its tests: the specs are two folders up.
+const E2E = resolve(process.cwd(), '../../e2e')
+const ALLOWED = /^(127\.0\.0\.1|localhost|host\.docker\.internal|www\.w3\.org|([a-z0-9-]+\.)*example(\.com|\.org|\.net)?|[a-z0-9.-]*\.example|[a-z0-9.-]*\.invalid)$/i
+
+describe('end to end specs', () => {
+ it('name no third party host', () => {
+ const offenders: string[] = []
+ for (const name of readdirSync(E2E).filter((f) => f.endsWith('.ts'))) {
+ const body = readFileSync(join(E2E, name), 'utf8')
+ for (const m of body.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)) {
+ if (!ALLOWED.test(m[1])) offenders.push(`${name}: ${m[1]}`)
+ }
+ }
+ expect(offenders).toEqual([])
+ })
+})
diff --git a/web/apps/desk/src/types/pim.ts b/web/apps/desk/src/types/pim.ts
index 2dce4f9d9..a20fa625c 100644
--- a/web/apps/desk/src/types/pim.ts
+++ b/web/apps/desk/src/types/pim.ts
@@ -29,7 +29,7 @@ export interface PIMMedia {
alt_text: string;
sort_order: number;
is_primary: boolean;
- status: 'generating' | 'ready' | 'failed';
+ status: 'generating' | 'ready' | 'failed' | 'refused';
gen_model: string;
gen_prompt: string;
gen_style: string;
diff --git a/web/e2e/admin.spec.ts b/web/e2e/admin.spec.ts
index c7eb07246..71b818196 100644
--- a/web/e2e/admin.spec.ts
+++ b/web/e2e/admin.spec.ts
@@ -70,14 +70,17 @@ test.describe('Tech admin flow on the new contract', () => {
await page.getByRole('button', { name: /add key|update key/i }).click();
await page.getByPlaceholder('sk-or-...').fill(`sk-or-e2e-${RUN}`);
- await page.getByPlaceholder(/openrouter\.ai/).fill('https://openrouter.ai/api/v1');
+ await page.getByPlaceholder(/openrouter\.ai/).fill('http://127.0.0.1:9/api/v1');
await page.screenshot({ path: path.join(SHOTS_DIR, 'admin-ai-save.png') });
await page.getByRole('button', { name: 'Save Key' }).click();
// The saved state shows the admin override with its masked hint and the
// base URL it stored; the save carried the revision it read, so it
- // cannot have clobbered a second editor's write.
- await expect(page.getByText('AI Features Active')).toBeVisible();
+ // cannot have clobbered a second editor's write. The save tested the
+ // key against the loopback address typed above, which nothing answers, so
+ // the test failed at once (item 130, N7) and the card reads off rather
+ // than active. No request left the machine.
+ await expect(page.getByText('AI Features Off')).toBeVisible();
await expect(page.getByText('Admin configured')).toBeVisible();
await page.screenshot({ path: path.join(SHOTS_DIR, 'admin-ai-saved.png') });
diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts
index 84c3cf3fc..c31f506c0 100644
--- a/web/packages/api-client/src/schema.d.ts
+++ b/web/packages/api-client/src/schema.d.ts
@@ -7487,11 +7487,20 @@ export interface components {
anthropic_configured: boolean;
anthropic_key_hint: string | null;
vault_present: boolean;
+ /**
+ * @description The last test of the saved key: none with no key saved, untested for a key never tested, passed, refused (the provider refused the key) or failed (the test could not finish). A save runs the test.
+ * @enum {string}
+ */
+ test_status: "none" | "untested" | "passed" | "refused" | "failed";
+ /** @description The plain sentence for refused and failed; absent otherwise. */
+ test_message?: string;
};
/** @description Go type techadmin.TestResult. A provider failure is ok false with its words, never the key. */
AdminTestAIResult: {
ok: boolean;
message: string;
+ /** @enum {string} */
+ status: "passed" | "refused" | "failed";
};
/** @description Go type techadmin.RoutingSettings, the AI settings' shape without the base URL. */
RoutingSettings: {
@@ -13367,8 +13376,11 @@ export interface components {
alt_text: string;
sort_order: number;
is_primary: boolean;
- /** @enum {string} */
- status: "generating" | "ready" | "failed";
+ /**
+ * @description refused is a generation the provider refused for the saved key (a retry cannot work; an administrator enters the key again in Tech Admin, AI Settings).
+ * @enum {string}
+ */
+ status: "generating" | "ready" | "failed" | "refused";
gen_model: string;
gen_prompt: string;
gen_style: string;
@@ -14669,6 +14681,15 @@ export interface components {
"application/json": components["schemas"]["WireError"];
};
};
+ /** @description The AI provider refused the saved key (code ai_refused; a 401 or 403 from the provider). It is not AI off: a key is saved and was not accepted. The message names the provider and says an administrator can enter the key again in Tech Admin, AI Settings. Nothing answers a canned result for it. ADR 0001 envelope. */
+ AIRefused: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WireError"];
+ };
+ };
/** @description AI is off (code ai_off): no key for the chosen provider, a stored key the vault cannot open, or a feature the chosen provider does not offer. The message says that an administrator turns it on in Tech Admin, AI. Or the idempotency layer reports the Idempotency-Key in progress (code idempotency_in_progress). Both are the ADR 0001 envelope. */
AIOffEither: {
headers: {
@@ -23297,6 +23318,7 @@ export interface operations {
415: components["responses"]["UnsupportedType"];
422: components["responses"]["UnprocessableEntity"];
500: components["responses"]["InternalError"];
+ 502: components["responses"]["AIRefused"];
};
};
partnerDashboard: {
@@ -26891,6 +26913,7 @@ export interface operations {
413: components["responses"]["PayloadTooLarge"];
422: components["responses"]["UnprocessableEntity"];
500: components["responses"]["InternalError"];
+ 502: components["responses"]["AIRefused"];
};
};
productPimGenerateDescriptions: {
@@ -26929,6 +26952,7 @@ export interface operations {
413: components["responses"]["PayloadTooLarge"];
422: components["responses"]["UnprocessableEntity"];
500: components["responses"]["InternalError"];
+ 502: components["responses"]["AIRefused"];
};
};
productPimGenerateImage: {
@@ -27005,6 +27029,7 @@ export interface operations {
413: components["responses"]["PayloadTooLarge"];
422: components["responses"]["UnprocessableEntity"];
500: components["responses"]["InternalError"];
+ 502: components["responses"]["AIRefused"];
};
};
productUpdateMargins: {
@@ -27731,6 +27756,7 @@ export interface operations {
415: components["responses"]["UnsupportedType"];
422: components["responses"]["UnprocessableEntity"];
500: components["responses"]["InternalError"];
+ 502: components["responses"]["AIRefused"];
};
};
purchaseOrderFreightApply: {