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 apiKey = getEnv(`${prefix}_API_KEY`) || '';
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);
}

View File

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

View File

@@ -15,6 +15,8 @@ export interface MoaModelConfig {
model: string;
baseUrl: string;
apiKey: string;
/** API format: 'openai' (default) or 'anthropic' (Messages API). */
apiFormat: 'openai' | 'anthropic';
}
export interface MoaConfig {
@@ -38,37 +40,70 @@ async function queryModel(
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(
`${model.baseUrl.replace(/\/+$/, '')}/chat/completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${model.apiKey}`,
},
body: JSON.stringify({
const base = model.baseUrl.replace(/\/+$/, '');
const isAnthropic = model.apiFormat === 'anthropic';
const url = isAnthropic
? `${base}/v1/messages`
: `${base}/chat/completions`;
const headers: Record<string, string> = {
'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,
system: systemPrompt,
max_tokens: 2048,
messages: [{ role: 'user', content: userPrompt }],
}
: {
model: model.model,
max_tokens: 2048,
messages: [
{ role: 'system', content: systemPrompt },
{ 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) {
const body = await response.text().catch(() => '');
const respBody = await response.text().catch(() => '');
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 {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
const data = await response.json();
// Parse response based on format
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');
return content;
} finally {