diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js index ffa038b35..40f907d52 100644 --- a/server/providers/mapillary/features.js +++ b/server/providers/mapillary/features.js @@ -26,7 +26,7 @@ function sendJson(res, status, payload) { } /** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ -export function parseEpochMs(value) { +function parseEpochMs(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; const parsed = Date.parse(String(value)); diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js index 1001b9371..0160a72ce 100644 --- a/server/providers/mapillary/planner.js +++ b/server/providers/mapillary/planner.js @@ -1,6 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; -import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import { readRequestBody } from '../common/request.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -26,7 +25,7 @@ import { * The plan the model returns. Every field is required (structured outputs * reject optional keys); "unknown" is spelled with null. */ -export const PlanSchema = z.object({ +const PlanSchema = z.object({ intent: z.enum([ 'map_features', 'traffic_signs', @@ -182,6 +181,35 @@ export function buildPlannerUserMessage({ query, context = {}, today }) { return lines.join('\n'); } +/** + * Structured-output format for the plan: the Zod schema as JSON Schema. Kept + * free of the SDK's zod helper so the provider only depends on package roots. + */ +function planOutputFormat() { + const { $schema: _schema, ...schema } = z.toJSONSchema(PlanSchema, { + reused: 'ref', + }); + return { type: 'json_schema', schema }; +} + +/** The plan object from a structured-output response, or null. */ +function parsePlanResponse(response) { + if (response?.parsed_output) return response.parsed_output; + const text = (response?.content || []) + .filter((block) => block?.type === 'text') + .map((block) => block.text) + .join(''); + if (!text.trim()) return null; + let raw; + try { + raw = JSON.parse(text); + } catch { + return null; + } + const result = PlanSchema.safeParse(raw); + return result.success ? result.data : null; +} + /** * Ask Claude for a plan. Exported for tests with an injectable client. * @returns {Promise<{plan: object, usage: object|null, model: string}>} @@ -190,7 +218,7 @@ export async function planQuery( { query, context }, { anthropic = client(), model = plannerModel(), now = new Date() } = {}, ) { - const response = await anthropic.messages.parse({ + const response = await anthropic.messages.create({ model, max_tokens: PLANNER_MAX_TOKENS, system: [ @@ -210,7 +238,7 @@ export async function planQuery( }), }, ], - output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + output_config: { format: planOutputFormat(), effort: 'low' }, }); if (response.stop_reason === 'refusal') { return { @@ -230,10 +258,11 @@ export async function planQuery( model, }; } - if (!response.parsed_output) + const parsed = parsePlanResponse(response); + if (!parsed) throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); return { - plan: finalizePlan(response.parsed_output), + plan: finalizePlan(parsed), usage: response.usage ?? null, model, }; diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js index c2f888555..15cf2ef15 100644 --- a/server/providers/mapillary/sprites.js +++ b/server/providers/mapillary/sprites.js @@ -1,3 +1,7 @@ +/** + * Sprite proxy. Icons come from github.com/mapillary/mapillary_sprite_source + * (MIT, © Mapillary), fetched on demand and cached on disk. + */ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import {