diff --git a/server/providers/common/textInstructions.js b/server/providers/common/textInstructions.js index 6efd596d0..ff4d62bbc 100644 --- a/server/providers/common/textInstructions.js +++ b/server/providers/common/textInstructions.js @@ -15,8 +15,9 @@ export const TEXT_WORDING = Object.freeze([ // Shared-manual adaptation follows Darren Dignam's GEV COMMAND approach (#976). // Keep our Radio handoff and annotation outcome policies from the shared manual. -export function textInstructions(annotationGuidance) { - return realtimeDirectives(annotationGuidance) +// `options.overpass` matches the tools offered, as for voice. +export function textInstructions(annotationGuidance, options) { + return realtimeDirectives(annotationGuidance, options) .map((line, index) => { if (index === 0) return "You are GEV Chat, a concise text assistant for a Cesium geospatial app called God's Eye View."; diff --git a/server/providers/gemini/chat.js b/server/providers/gemini/chat.js index 73e10502d..4e4dbea45 100644 --- a/server/providers/gemini/chat.js +++ b/server/providers/gemini/chat.js @@ -1,7 +1,8 @@ import { createTextChatHandler } from '../common/textChat.js'; import { invalidCalls } from '../common/textTools.js'; import { textInstructions } from '../common/textInstructions.js'; -import { GEV_REALTIME_TOOLS } from '../openai/tools.js'; +import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js'; +import { resolveOverpassUpstreams } from '../overpass/constants.js'; import { normalizeGeminiModel } from './config.js'; // The Gemini token route's throttle default (GEV_RATELIMIT_GEMINI_PER_MIN). @@ -80,6 +81,9 @@ export function validChatContents(contents, names) { export function createGeminiChatHandler({ annotationGuidance, tools = GEV_REALTIME_TOOLS, + // osm_query is offered, and the prompt names it, only with Overpass, as + // for voice. + overpassConfigured = () => resolveOverpassUpstreams().length > 0, resolveChatModel = () => process.env.GEMINI_CHAT_MODEL || 'gemini-3.5-flash-lite', resolveApiKey = () => process.env.GEMINI_API_KEY, @@ -102,17 +106,20 @@ export function createGeminiChatHandler({ ) return null; if (!pairedContents(input.contents)) return null; + const overpass = overpassConfigured(); return { endpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, headers: (key) => ({ 'x-goog-api-key': key }), body: { contents: input.contents, systemInstruction: { - parts: [{ text: textInstructions(annotationGuidance) }], + parts: [ + { text: textInstructions(annotationGuidance, { overpass }) }, + ], }, tools: [ { - functionDeclarations: tools.map( + functionDeclarations: offeredTools(tools, { overpass }).map( ({ name, description, parameters }) => ({ name, description, @@ -144,7 +151,10 @@ export function createGeminiChatHandler({ return { content, finishReason: candidate.finishReason, - invalidCalls: invalidCalls(calls, tools), + invalidCalls: invalidCalls( + calls, + offeredTools(tools, { overpass: overpassConfigured() }), + ), }; }, }); diff --git a/server/providers/openai/chat.js b/server/providers/openai/chat.js index cd0123486..413d69ba9 100644 --- a/server/providers/openai/chat.js +++ b/server/providers/openai/chat.js @@ -3,7 +3,8 @@ import { preferCodexOAuth } from './codex-auth.js'; import { OPENAI_DEFAULT_PER_MIN } from './rate-limit.js'; import { invalidCalls } from '../common/textTools.js'; import { textInstructions } from '../common/textInstructions.js'; -import { GEV_REALTIME_TOOLS } from './tools.js'; +import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js'; +import { resolveOverpassUpstreams } from '../overpass/constants.js'; const object = (value) => value && typeof value === 'object' && !Array.isArray(value); @@ -66,6 +67,9 @@ function validMessages(messages) { export function createOpenAiChatHandler({ annotationGuidance, tools = GEV_REALTIME_TOOLS, + // osm_query is offered, and the prompt names it, only with Overpass, as + // for voice. + overpassConfigured = () => resolveOverpassUpstreams().length > 0, resolveApiKey = () => process.env.OPENAI_API_KEY, resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini', resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_OPENAI_PER_MIN, @@ -102,6 +106,7 @@ export function createOpenAiChatHandler({ !validMessages(input.messages) ) return null; + const overpass = overpassConfigured(); return { endpoint: 'https://api.openai.com/v1/chat/completions', headers: (key) => ({ Authorization: `Bearer ${key}` }), @@ -112,13 +117,18 @@ export function createOpenAiChatHandler({ store: false, max_completion_tokens: 4096, messages: [ - { role: 'system', content: textInstructions(annotationGuidance) }, + { + role: 'system', + content: textInstructions(annotationGuidance, { overpass }), + }, ...input.messages, ], - tools: tools.map(({ name, description, parameters }) => ({ - type: 'function', - function: { name, description, parameters, strict: false }, - })), + tools: offeredTools(tools, { overpass }).map( + ({ name, description, parameters }) => ({ + type: 'function', + function: { name, description, parameters, strict: false }, + }), + ), }, }; }, @@ -149,7 +159,10 @@ export function createOpenAiChatHandler({ return { content, finishReason: choice.finish_reason, - invalidCalls: invalidCalls(calls, tools), + invalidCalls: invalidCalls( + calls, + offeredTools(tools, { overpass: overpassConfigured() }), + ), }; }, }); diff --git a/src/textChatServer.test.mjs b/src/textChatServer.test.mjs index bd1dddbfb..61b09e987 100644 --- a/src/textChatServer.test.mjs +++ b/src/textChatServer.test.mjs @@ -4,6 +4,7 @@ import http from 'node:http'; import { once } from 'node:events'; import { createGeminiChatHandler } from '../server/providers/gemini/chat.js'; import { createOpenAiChatHandler } from '../server/providers/openai/chat.js'; +import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import { realtimeDirectives, realtimeInstructions, @@ -514,6 +515,27 @@ for (const [name, provider] of Object.entries(providers)) { assert.equal((await (await unread.send()).json()).code, 'API_KEY_REJECTED'); assert.equal(cancelled, true); }); + test(`${name} offers osm_query, and names it, only with Overpass, as voice does`, async (t) => { + for (const overpass of [false, true]) { + const f = await fixture(t, provider, undefined, { + tools: GEV_REALTIME_TOOLS, + overpassConfigured: () => overpass, + }); + assert.equal((await f.send()).status, 200); + const { body } = f.calls[0]; + const offered = + name === 'openai' + ? body.tools.map((tool) => tool.function.name) + : body.tools[0].functionDeclarations.map((tool) => tool.name); + const prompt = + name === 'openai' + ? body.messages[0].content + : body.systemInstruction.parts[0].text; + assert.equal(offered.includes('osm_query'), overpass); + assert.equal(prompt.includes('osm_query'), overpass); + assert.ok(offered.includes('resolve_area')); + } + }); test(`${name} throttled replies say they are rate limited`, async (t) => { const local = await fixture(t, provider, undefined, { resolveRateLimit: () => '1',