feat: MoA supports Anthropic Messages format (MOA_{NAME}_API_FORMAT=anthropic)

Kimi coding plan and GLM both use Anthropic-compatible API, not OpenAI.
queryModel now dispatches to /v1/messages or /chat/completions based on
apiFormat config per reference model.
This commit is contained in:
Eyejoker
2026-03-31 01:49:34 +09:00
parent a89ab757b6
commit da27d30079
3 changed files with 62 additions and 30 deletions

View File

@@ -183,7 +183,10 @@ function parseMoaReferenceModels(): MoaModelConfig[] {
const baseUrl = getEnv(`${prefix}_BASE_URL`) || ''; const baseUrl = getEnv(`${prefix}_BASE_URL`) || '';
const apiKey = getEnv(`${prefix}_API_KEY`) || ''; const apiKey = getEnv(`${prefix}_API_KEY`) || '';
if (!model || !baseUrl || !apiKey) return null; if (!model || !baseUrl || !apiKey) return null;
return { name, model, baseUrl, apiKey }; const rawFormat = getEnv(`${prefix}_API_FORMAT`) || '';
const apiFormat: 'openai' | 'anthropic' =
rawFormat === 'anthropic' ? 'anthropic' : 'openai';
return { name, model, baseUrl, apiKey, apiFormat };
}) })
.filter((m): m is MoaModelConfig => m !== null); .filter((m): m is MoaModelConfig => m !== null);
} }

View File

@@ -60,13 +60,9 @@ let cachedData: KimiUsageData | null = null;
function getKimiConfig(): { baseUrl: string; authToken: string } | null { function getKimiConfig(): { baseUrl: string; authToken: string } | null {
// Try MoA config first, then fallback config // Try MoA config first, then fallback config
const authToken = const authToken =
getEnv('MOA_KIMI_API_KEY') || getEnv('MOA_KIMI_API_KEY') || getEnv('FALLBACK_AUTH_TOKEN') || '';
getEnv('FALLBACK_AUTH_TOKEN') ||
'';
const baseUrl = const baseUrl =
getEnv('MOA_KIMI_BASE_URL') || getEnv('MOA_KIMI_BASE_URL') || getEnv('FALLBACK_BASE_URL') || '';
getEnv('FALLBACK_BASE_URL') ||
'';
if (!baseUrl || !authToken) return null; if (!baseUrl || !authToken) return null;
return { baseUrl, authToken }; return { baseUrl, authToken };
} }
@@ -184,9 +180,7 @@ function formatMembershipLabel(level?: string): string {
return labelMap[cleaned] || cleaned; return labelMap[cleaned] || cleaned;
} }
export function buildKimiUsageRows( export function buildKimiUsageRows(data: KimiUsageData | null): UsageRow[] {
data: KimiUsageData | null,
): UsageRow[] {
if (!data) return []; if (!data) return [];
const memberLabel = formatMembershipLabel(data.membershipLevel); const memberLabel = formatMembershipLabel(data.membershipLevel);

View File

@@ -15,6 +15,8 @@ export interface MoaModelConfig {
model: string; model: string;
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
/** API format: 'openai' (default) or 'anthropic' (Messages API). */
apiFormat: 'openai' | 'anthropic';
} }
export interface MoaConfig { export interface MoaConfig {
@@ -38,37 +40,70 @@ async function queryModel(
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch( const base = model.baseUrl.replace(/\/+$/, '');
`${model.baseUrl.replace(/\/+$/, '')}/chat/completions`, const isAnthropic = model.apiFormat === 'anthropic';
{
method: 'POST', const url = isAnthropic
headers: { ? `${base}/v1/messages`
'Content-Type': 'application/json', : `${base}/chat/completions`;
Authorization: `Bearer ${model.apiKey}`,
}, const headers: Record<string, string> = {
body: JSON.stringify({ 'Content-Type': 'application/json',
};
if (isAnthropic) {
headers['x-api-key'] = model.apiKey;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${model.apiKey}`;
}
const body = isAnthropic
? {
model: model.model, model: model.model,
system: systemPrompt,
max_tokens: 2048,
messages: [{ role: 'user', content: userPrompt }],
}
: {
model: model.model,
max_tokens: 2048,
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }, { role: 'user', content: userPrompt },
], ],
max_tokens: 2048, };
}),
signal: controller.signal, const response = await fetch(url, {
}, method: 'POST',
); headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ''); const respBody = await response.text().catch(() => '');
throw new Error( throw new Error(
`${response.status} ${response.statusText}: ${body.slice(0, 200)}`, `${response.status} ${response.statusText}: ${respBody.slice(0, 200)}`,
); );
} }
const data = (await response.json()) as { const data = await response.json();
choices?: { message?: { content?: string } }[];
}; // Parse response based on format
const content = data.choices?.[0]?.message?.content; let content: string | undefined;
if (isAnthropic) {
const blocks = (data as { content?: { type: string; text: string }[] })
.content;
content = blocks
?.filter((b: { type: string }) => b.type === 'text')
.map((b: { text: string }) => b.text)
.join('');
} else {
content = (
data as { choices?: { message?: { content?: string } }[] }
).choices?.[0]?.message?.content;
}
if (!content) throw new Error('Empty response from model'); if (!content) throw new Error('Empty response from model');
return content; return content;
} finally { } finally {