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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
71
src/moa.ts
71
src/moa.ts
@@ -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`
|
||||||
|
: `${base}/chat/completions`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${model.apiKey}`,
|
};
|
||||||
},
|
if (isAnthropic) {
|
||||||
body: JSON.stringify({
|
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,
|
};
|
||||||
}),
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
signal: controller.signal,
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user