mirror of
https://github.com/hanzo-apps/world.git
synced 2026-10-30 14:53:09 +00:00
You asked why we were using Groq. We were not — and that is the bug.
`internal/world/ai.go` has always pointed at api.hanzo.ai/v1 with model "best",
and `handleSummarize` backs BOTH /v1/world/groq-summarize and
/v1/world/openrouter-summarize with that one Hanzo-backed handler. CI ships the
Go operator Service CR; vercel.json declares no api routes, so the Vercel
`api/*.js` edge functions that really did call api.groq.com and openrouter.ai
are not deployed at all.
So production ran on our stack the whole time. What shipped was the NAMING, and
it lied in every direction:
- the client logged `[Summarization] Groq success` — chosen from the ROUTE NAME,
not from anything Groq did. That console line is what made a security review
report a competing AI stack running inside a Hanzo surface.
- the progress UI told users "Connecting to Groq AI..." and "Trying OpenRouter..."
while Hanzo served the request.
- Settings offered two features, "Groq summarization" and "OpenRouter
summarization", each asking for a provider key, with signup links to
console.groq.com and openrouter.ai — sending our users to competitors to buy
keys for calls we were already making and paying for.
- 13 locales told users to "configure GROQ_API_KEY in Settings", a setting that
does nothing.
And because both endpoints were one handler, `tryGroq` and `tryOpenRouter` were
never two providers: the "fallback chain" was the identical request twice, a
retry wearing a second vendor's name. Same for the translate path.
Now: one `/v1/world/summarize`, one `tryHanzo`, one `aiSummary` feature, and no
per-user provider key — the gateway takes the signed-in user's bearer, so the
inference meters to the org the user is acting in. The two old routes stay as
aliases so a cached bundle cannot 404 mid-rollout; delete them once nothing asks.
The `api/*.js` handlers are repointed at the Hanzo gateway through one shared
`api/_hanzo-ai.js` rather than left calling Groq. They are dead in this
deployment, but dead code that still holds a third-party endpoint and reads
GROQ_API_KEY is exactly what gets revived by accident.
Groq and OpenRouter still appear in `src/config/ai-datacenters.ts` and
`tech-companies.ts`. That is deliberate: world MAPS real companies and their
datacenters, and Groq is one. Data about a competitor is not a dependency on one.
Not done: 12 non-English locales had the vendor token swapped out to keep them
truthful, but their sentences still imply a setting that no longer exists
("configure Hanzo AI in Settings"). They want a real copy pass by someone who
speaks them; I will not fabricate translations.
Co-authored-by: Hanzo Dev <[email protected]>
199 lines
6.6 KiB
JavaScript
199 lines
6.6 KiB
JavaScript
import { getCachedJson, setCachedJson, mget, hashString } from './_upstash-cache.js';
|
|
import { HANZO_API_URL, FAST_MODEL, hanzoKey } from './_hanzo-ai.js';
|
|
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
|
|
|
export const config = {
|
|
runtime: 'edge',
|
|
};
|
|
|
|
const MODEL = FAST_MODEL;
|
|
const CACHE_TTL_SECONDS = 86400;
|
|
const CACHE_VERSION = 'v1';
|
|
const MAX_BATCH_SIZE = 20;
|
|
|
|
const VALID_LEVELS = ['critical', 'high', 'medium', 'low', 'info'];
|
|
const VALID_CATEGORIES = [
|
|
'conflict', 'protest', 'disaster', 'diplomatic', 'economic',
|
|
'terrorism', 'cyber', 'health', 'environmental', 'military',
|
|
'crime', 'infrastructure', 'tech', 'general',
|
|
];
|
|
|
|
export default async function handler(request) {
|
|
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
|
|
|
|
if (request.method === 'OPTIONS') {
|
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
}
|
|
|
|
if (request.method !== 'POST') {
|
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (isDisallowedOrigin(request)) {
|
|
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const apiKey = hanzoKey();
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ results: [], fallback: true, skipped: true, reason: 'HANZO_API_KEY not configured' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
|
|
if (contentLength > 51200) {
|
|
return new Response(JSON.stringify({ error: 'Payload too large' }), {
|
|
status: 413,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const { titles, variant = 'full' } = body;
|
|
if (!Array.isArray(titles) || titles.length === 0) {
|
|
return new Response(JSON.stringify({ error: 'titles array required' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const batch = titles.slice(0, MAX_BATCH_SIZE);
|
|
const results = new Array(batch.length).fill(null);
|
|
const uncachedIndices = [];
|
|
|
|
const cacheKeys = batch.map(
|
|
(t) => `classify:${CACHE_VERSION}:${hashString(t.toLowerCase() + ':' + variant)}`
|
|
);
|
|
const cached = await mget(...cacheKeys);
|
|
for (let i = 0; i < cached.length; i++) {
|
|
const val = cached[i];
|
|
if (val && typeof val === 'object' && val.level) {
|
|
results[i] = { level: val.level, category: val.category, cached: true };
|
|
} else {
|
|
uncachedIndices.push(i);
|
|
}
|
|
}
|
|
|
|
if (uncachedIndices.length === 0) {
|
|
return new Response(JSON.stringify({ results }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
|
|
});
|
|
}
|
|
|
|
const uncachedTitles = uncachedIndices.map((i) => batch[i]);
|
|
const isTech = variant === 'tech';
|
|
const numberedList = uncachedTitles.map((t, i) => `${i + 1}. ${t}`).join('\n');
|
|
|
|
const systemPrompt = `You classify news headlines into threat level and category. Return ONLY a valid JSON array, no other text.
|
|
|
|
Levels: critical, high, medium, low, info
|
|
Categories: conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general
|
|
|
|
${isTech ? 'Focus: technology, startups, AI, cybersecurity. Most tech news is "low" or "info" unless it involves outages, breaches, or major disruptions.' : 'Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.'}
|
|
|
|
Return a JSON array with one object per headline in order: [{"level":"...","category":"..."},...]`;
|
|
|
|
try {
|
|
const response = await fetch(HANZO_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: numberedList },
|
|
],
|
|
temperature: 0,
|
|
max_tokens: uncachedTitles.length * 60,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error('[ClassifyBatch] model error:', response.status);
|
|
return new Response(JSON.stringify({ results, fallback: true }), {
|
|
status: response.status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const data = await response.json();
|
|
const raw = data.choices?.[0]?.message?.content?.trim();
|
|
if (!raw) {
|
|
return new Response(JSON.stringify({ results, fallback: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
const match = raw.match(/\[[\s\S]*\]/);
|
|
if (match) {
|
|
try { parsed = JSON.parse(match[0]); } catch { /* fall through */ }
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
return new Response(JSON.stringify({ results, fallback: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const cacheWrites = [];
|
|
for (let i = 0; i < uncachedIndices.length; i++) {
|
|
const classification = parsed[i];
|
|
if (!classification) continue;
|
|
|
|
const level = VALID_LEVELS.includes(classification.level) ? classification.level : null;
|
|
const category = VALID_CATEGORIES.includes(classification.category) ? classification.category : null;
|
|
if (!level || !category) continue;
|
|
|
|
const idx = uncachedIndices[i];
|
|
results[idx] = { level, category, cached: false };
|
|
|
|
const cacheKey = `classify:${CACHE_VERSION}:${hashString(batch[idx].toLowerCase() + ':' + variant)}`;
|
|
cacheWrites.push(
|
|
setCachedJson(cacheKey, { level, category, timestamp: Date.now() }, CACHE_TTL_SECONDS)
|
|
);
|
|
}
|
|
|
|
if (cacheWrites.length > 0) {
|
|
await Promise.allSettled(cacheWrites);
|
|
}
|
|
|
|
return new Response(JSON.stringify({ results }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
|
|
});
|
|
} catch (error) {
|
|
console.error('[ClassifyBatch] Error:', error.message);
|
|
return new Response(JSON.stringify({ results, fallback: true }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|