Skip to content

Commit ae8cd1e

Browse files
committed
feat(search-nlp): rebuild providers on AI SDK
1 parent 47dcb30 commit ae8cd1e

45 files changed

Lines changed: 2508 additions & 1362 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/utils/__tests__/validate-config-body.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, it } from "vitest";
23
import { getSecretFields, validateConfigBody } from "../validate-config-body";
34

@@ -24,6 +25,13 @@ const schema = {
2425
},
2526
};
2627

28+
const searchNlpManifest = JSON.parse(
29+
readFileSync(
30+
new URL("../../../../../integrations/search-nlp/manifest.json", import.meta.url),
31+
"utf8",
32+
),
33+
) as { configSchema: Record<string, unknown> };
34+
2735
describe("getSecretFields", () => {
2836
it("extracts only fields marked x-openmapx-secret", () => {
2937
const fields = getSecretFields(schema);
@@ -169,6 +177,164 @@ describe("validateConfigBody valid values", () => {
169177
});
170178
});
171179

180+
describe("validateConfigBody nested schemas", () => {
181+
const nestedSchema = {
182+
properties: {
183+
providers: {
184+
type: "array",
185+
minItems: 1,
186+
items: {
187+
oneOf: [
188+
{
189+
type: "object",
190+
required: ["id", "type"],
191+
additionalProperties: false,
192+
properties: {
193+
id: { type: "string", pattern: "^[a-z]+$" },
194+
type: { const: "keyword" },
195+
},
196+
},
197+
{
198+
type: "object",
199+
required: ["id", "type", "baseURL"],
200+
additionalProperties: false,
201+
properties: {
202+
id: { type: "string", pattern: "^[a-z]+$" },
203+
type: { const: "compatible" },
204+
baseURL: { type: "string", format: "url" },
205+
},
206+
},
207+
],
208+
},
209+
},
210+
},
211+
};
212+
213+
it("accepts arrays of discriminated objects", () => {
214+
const providers = [
215+
{ id: "remote", type: "compatible", baseURL: "https://models.example/v1" },
216+
{ id: "keyword", type: "keyword" },
217+
];
218+
expect(validateConfigBody({ providers }, nestedSchema)).toEqual({
219+
updates: { providers },
220+
errors: [],
221+
});
222+
});
223+
224+
it("rejects invalid variants, nested URLs, and extra properties", () => {
225+
expect(
226+
validateConfigBody(
227+
{ providers: [{ id: "remote", type: "compatible", baseURL: "file:///tmp/x" }] },
228+
nestedSchema,
229+
).errors,
230+
).toEqual(['"providers"[0] does not match an allowed shape for type "compatible"']);
231+
expect(
232+
validateConfigBody(
233+
{ providers: [{ id: "keyword", type: "keyword", secret: "nope" }] },
234+
nestedSchema,
235+
).errors,
236+
).toEqual(['"providers"[0] does not match an allowed shape for type "keyword"']);
237+
});
238+
239+
it("enforces collection bounds", () => {
240+
expect(validateConfigBody({ providers: [] }, nestedSchema).errors).toEqual([
241+
'"providers" must contain at least 1 item(s)',
242+
]);
243+
});
244+
245+
it("supports conditional requirements", () => {
246+
const conditionalSchema = JSON.parse(`{
247+
"properties": {
248+
"provider": {
249+
"type": "object",
250+
"properties": {
251+
"local": { "type": "boolean" },
252+
"processor": { "type": "string" }
253+
},
254+
"allOf": [{
255+
"if": { "type": "object", "properties": { "local": { "const": false } } },
256+
"then": { "type": "object", "required": ["processor"] }
257+
}]
258+
}
259+
}
260+
}`) as Record<string, unknown>;
261+
262+
expect(validateConfigBody({ provider: { local: false } }, conditionalSchema).errors).toEqual([
263+
'"provider".processor is required',
264+
]);
265+
expect(validateConfigBody({ provider: { local: true } }, conditionalSchema).errors).toEqual([]);
266+
});
267+
});
268+
269+
describe("search-nlp manifest provider schema", () => {
270+
const processor = {
271+
id: "groq",
272+
name: "Groq",
273+
countryCode: "US",
274+
privacyUrl: "https://groq.com/privacy-policy/",
275+
};
276+
277+
it("accepts maintained, local, and custom cloud provider definitions", () => {
278+
const providers = [
279+
{ id: "gemini", type: "google", model: "gemini-2.5-flash" },
280+
{ id: "router", type: "openrouter", model: "openai/gpt-4.1-mini" },
281+
{
282+
id: "local-compatible",
283+
type: "openai-compatible",
284+
model: "local-model",
285+
baseURL: "http://local-ai:8000/v1",
286+
credential: "none",
287+
local: true,
288+
},
289+
{
290+
id: "groq",
291+
type: "openai-compatible",
292+
model: "llama-3.3-70b-versatile",
293+
baseURL: "https://api.groq.com/openai/v1",
294+
processor,
295+
},
296+
];
297+
298+
expect(validateConfigBody({ providers }, searchNlpManifest.configSchema)).toEqual({
299+
updates: { providers },
300+
errors: [],
301+
});
302+
});
303+
304+
it("rejects undisclosed or plaintext custom cloud providers", () => {
305+
const withoutDisclosure = validateConfigBody(
306+
{
307+
providers: [
308+
{
309+
id: "custom",
310+
type: "openai-compatible",
311+
model: "model",
312+
baseURL: "https://models.example.com/v1",
313+
},
314+
],
315+
},
316+
searchNlpManifest.configSchema,
317+
);
318+
expect(withoutDisclosure.errors).toHaveLength(1);
319+
320+
const plaintext = validateConfigBody(
321+
{
322+
providers: [
323+
{
324+
id: "custom",
325+
type: "openai-compatible",
326+
model: "model",
327+
baseURL: "http://models.example.com/v1",
328+
processor: { ...processor, id: "custom" },
329+
},
330+
],
331+
},
332+
searchNlpManifest.configSchema,
333+
);
334+
expect(plaintext.errors).toHaveLength(1);
335+
});
336+
});
337+
172338
describe("validateConfigBody unknown and type errors", () => {
173339
it("rejects an unknown config key", () => {
174340
const result = validateConfigBody({ nope: 1 }, schema);

apps/api/src/utils/validate-config-body.ts

Lines changed: 134 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
* JSON-Schema-shaped manifest `configSchema` block used by both integrations
44
* and services.
55
*
6-
* Supports the field shapes the admin config form actually emits:
7-
* - `type: "boolean" | "number" | "integer" | "string"`
8-
* - `enum: unknown[]`
6+
* Supports the JSON Schema subset emitted by integration manifests:
7+
* - scalar, array, and object types
8+
* - enum / const / oneOf / allOf / if-then-else
9+
* - required and additionalProperties
10+
* - string, numeric, and collection bounds
11+
* - nested URL validation
912
* - `x-openmapx-secret: true` (must be set via credentials API, not config)
1013
*
11-
* Anything more complex (oneOf, refs, nested objects, arrays) falls through
12-
* unchanged — those shapes don't render in the form yet anyway. The validator
13-
* returns an `{ updates, errors }` pair; callers persist `updates` only when
14-
* `errors` is empty.
14+
* The validator returns an `{ updates, errors }` pair; callers persist updates
15+
* only when errors is empty.
1516
*/
1617

1718
import { type CredentialSetup, readCredentialSetup } from "@openmapx/integration-framework";
@@ -70,6 +71,129 @@ export interface ValidateConfigOptions {
7071
rejectSecrets?: boolean;
7172
}
7273

74+
function isRecord(value: unknown): value is Record<string, unknown> {
75+
return typeof value === "object" && value !== null && !Array.isArray(value);
76+
}
77+
78+
function validateSchemaValue(
79+
value: unknown,
80+
schema: Record<string, unknown>,
81+
path: string,
82+
): string[] {
83+
if (Array.isArray(schema.allOf)) {
84+
const { allOf, ...baseSchema } = schema;
85+
return [
86+
...validateSchemaValue(value, baseSchema, path),
87+
...allOf.flatMap((entry) =>
88+
isRecord(entry)
89+
? validateSchemaValue(value, entry, path)
90+
: [`${path} has an invalid schema`],
91+
),
92+
];
93+
}
94+
95+
if (isRecord(schema.if)) {
96+
const { if: condition, then, else: otherwise, ...baseSchema } = schema;
97+
const conditionMatches = validateSchemaValue(value, condition, path).length === 0;
98+
const branch = conditionMatches ? then : otherwise;
99+
return [
100+
...validateSchemaValue(value, baseSchema, path),
101+
...(isRecord(branch) ? validateSchemaValue(value, branch, path) : []),
102+
];
103+
}
104+
105+
const oneOf = schema.oneOf;
106+
if (Array.isArray(oneOf)) {
107+
const candidateResults = oneOf.map((candidate) =>
108+
isRecord(candidate)
109+
? validateSchemaValue(value, candidate, path)
110+
: [`${path} has an invalid schema`],
111+
);
112+
const matches = candidateResults.filter((errors) => errors.length === 0);
113+
if (matches.length === 1) return [];
114+
if (matches.length > 1) return [`${path} matches more than one allowed shape`];
115+
const discriminator =
116+
isRecord(value) && typeof value.type === "string" ? ` for type "${value.type}"` : "";
117+
return [`${path} does not match an allowed shape${discriminator}`];
118+
}
119+
120+
if ("const" in schema && value !== schema.const) {
121+
return [`${path} must equal ${JSON.stringify(schema.const)}`];
122+
}
123+
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
124+
return [`${path} must be one of: ${schema.enum.join(", ")}`];
125+
}
126+
127+
const type = schema.type;
128+
if (type === "boolean" && typeof value !== "boolean") return [`${path} must be a boolean`];
129+
if (type === "string") {
130+
if (typeof value !== "string") return [`${path} must be a string`];
131+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
132+
return [`${path} must have at least ${schema.minLength} characters`];
133+
}
134+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
135+
return [`${path} must have at most ${schema.maxLength} characters`];
136+
}
137+
if (typeof schema.pattern === "string" && !new RegExp(schema.pattern).test(value)) {
138+
return [`${path} has an invalid format`];
139+
}
140+
if (schema.format === "url" && value !== "") {
141+
try {
142+
const parsed = new URL(value);
143+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
144+
return [`${path} must be a valid http(s) URL`];
145+
}
146+
} catch {
147+
return [`${path} must be a valid http(s) URL`];
148+
}
149+
}
150+
return [];
151+
}
152+
if (type === "number" || type === "integer") {
153+
if (typeof value !== "number" || !Number.isFinite(value)) return [`${path} must be a number`];
154+
if (type === "integer" && !Number.isInteger(value)) return [`${path} must be an integer`];
155+
if (typeof schema.minimum === "number" && value < schema.minimum) {
156+
return [`${path} must be at least ${schema.minimum}`];
157+
}
158+
if (typeof schema.maximum === "number" && value > schema.maximum) {
159+
return [`${path} must be at most ${schema.maximum}`];
160+
}
161+
return [];
162+
}
163+
if (type === "array") {
164+
if (!Array.isArray(value)) return [`${path} must be an array`];
165+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
166+
return [`${path} must contain at least ${schema.minItems} item(s)`];
167+
}
168+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
169+
return [`${path} must contain at most ${schema.maxItems} item(s)`];
170+
}
171+
if (!isRecord(schema.items)) return [];
172+
return value.flatMap((entry, index) =>
173+
validateSchemaValue(entry, schema.items as Record<string, unknown>, `${path}[${index}]`),
174+
);
175+
}
176+
if (type === "object") {
177+
if (!isRecord(value)) return [`${path} must be an object`];
178+
const properties = isRecord(schema.properties) ? schema.properties : {};
179+
const required = Array.isArray(schema.required) ? new Set(schema.required) : new Set<unknown>();
180+
const errors: string[] = [];
181+
for (const key of required) {
182+
if (typeof key === "string" && !(key in value)) errors.push(`${path}.${key} is required`);
183+
}
184+
for (const [key, nestedValue] of Object.entries(value)) {
185+
const nestedSchema = properties[key];
186+
if (!isRecord(nestedSchema)) {
187+
if (schema.additionalProperties === false) errors.push(`${path}.${key} is not allowed`);
188+
continue;
189+
}
190+
errors.push(...validateSchemaValue(nestedValue, nestedSchema, `${path}.${key}`));
191+
}
192+
return errors;
193+
}
194+
return [];
195+
}
196+
73197
export function validateConfigBody(
74198
body: unknown,
75199
configSchema: Record<string, unknown> | undefined,
@@ -104,34 +228,9 @@ export function validateConfigBody(
104228
continue;
105229
}
106230

107-
const type = def.type as string | undefined;
108-
if (type === "boolean" && typeof value !== "boolean") {
109-
result.errors.push(`"${key}" must be a boolean`);
110-
continue;
111-
}
112-
if ((type === "number" || type === "integer") && typeof value !== "number") {
113-
result.errors.push(`"${key}" must be a number`);
114-
continue;
115-
}
116-
if (type === "string" && typeof value !== "string") {
117-
result.errors.push(`"${key}" must be a string`);
118-
continue;
119-
}
120-
const format = def.format as string | undefined;
121-
if (format === "url" && typeof value === "string" && value !== "") {
122-
let parsed: URL | null = null;
123-
try {
124-
parsed = new URL(value);
125-
} catch {
126-
parsed = null;
127-
}
128-
if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
129-
result.errors.push(`"${key}" must be a valid http(s) URL`);
130-
continue;
131-
}
132-
}
133-
if (def.enum && !(def.enum as unknown[]).includes(value)) {
134-
result.errors.push(`"${key}" must be one of: ${(def.enum as unknown[]).join(", ")}`);
231+
const errors = validateSchemaValue(value, def, `"${key}"`);
232+
if (errors.length > 0) {
233+
result.errors.push(...errors);
135234
continue;
136235
}
137236

0 commit comments

Comments
 (0)