feat(ai): add Together AI provider

This commit is contained in:
Mario Zechner
2026-05-08 16:44:18 +02:00
parent 9751057be9
commit 7adb8e7634
30 changed files with 714 additions and 56 deletions

View File

@@ -178,6 +178,18 @@ describe("AI Providers Abort Tests", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Abort", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should abort mid-stream", { retry: 3 }, async () => {
await testAbortSignal(llm, { reasoningEffort: "high" });
});
it("should handle immediate abort", { retry: 3 }, async () => {
await testImmediateAbort(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
const llm = getModel("minimax", "MiniMax-M2.7");

View File

@@ -325,6 +325,22 @@ describe("Context overflow error handling", () => {
}, 120000);
});
// =============================================================================
// Together AI
// Uses OpenAI-compatible Chat Completions API
// =============================================================================
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI", () => {
it("Kimi-K2.6 - should detect overflow via isContextOverflow", async () => {
const model = getModel("together", "moonshotai/Kimi-K2.6");
const result = await testContextOverflow(model, process.env.TOGETHER_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// z.ai
// Special case: may return explicit overflow error text, may accept overflow silently,

View File

@@ -107,6 +107,8 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
{ provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" },
// Hugging Face
{ provider: "huggingface", model: "moonshotai/Kimi-K2.5", label: "huggingface-kimi-k2.5" },
// Together AI
{ provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" },
// Kimi For Coding
{ provider: "kimi-coding", model: "kimi-k2-thinking", label: "kimi-coding-k2-thinking" },
// Mistral

View File

@@ -367,6 +367,26 @@ describe("AI Providers Empty Message Tests", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Empty Messages", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Empty Messages", () => {
const llm = getModel("zai", "glm-4.5-air");

View File

@@ -298,6 +298,19 @@ describe("Tool Results with Images", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K2.6)", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithImageResult(llm, options);
});
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
await handleToolWithTextAndImageResult(llm, options);
});
});
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider (mimo-v2.5-pro)", () => {
const llm = getModel("xiaomi", "mimo-v2.5-pro");

View File

@@ -35,6 +35,13 @@ describe("isContextOverflow", () => {
expect(isContextOverflow(message, 32768)).toBe(true);
});
it("detects Together AI context length errors", () => {
const message = createErrorMessage(
"400 The input (516368 tokens) is longer than the model's context length (262144 tokens).",
);
expect(isContextOverflow(message, 262144)).toBe(true);
});
it("does not treat generic non-overflow Ollama errors as overflow", () => {
const message = createErrorMessage("500 `model runner crashed unexpectedly`");
expect(isContextOverflow(message, 32768)).toBe(false);

View File

@@ -759,6 +759,34 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K2.6 via OpenAI Completions)", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, { reasoningEffort: "high" });
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, { reasoningEffort: "high" });
});
it("should handle image input", { retry: 3 }, async () => {
await handleImage(llm);
});
});
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v via OpenAI Completions)", () => {
const llm = getModel("openrouter", "z-ai/glm-4.5v");

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from "vitest";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.js";
import { getModel } from "../src/models.js";
const originalTogetherApiKey = process.env.TOGETHER_API_KEY;
afterEach(() => {
if (originalTogetherApiKey === undefined) {
delete process.env.TOGETHER_API_KEY;
} else {
process.env.TOGETHER_API_KEY = originalTogetherApiKey;
}
});
describe("Together models", () => {
it("registers the default Kimi K2.6 model via OpenAI-compatible Chat Completions API", () => {
const model = getModel("together", "moonshotai/Kimi-K2.6");
expect(model).toBeDefined();
expect(model.api).toBe("openai-completions");
expect(model.provider).toBe("together");
expect(model.baseUrl).toBe("https://api.together.ai/v1");
expect(model.reasoning).toBe(true);
expect(model.thinkingLevelMap).toEqual({ minimal: null, low: null, medium: null });
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(262144);
expect(model.maxTokens).toBe(131000);
expect(model.cost).toEqual({
input: 1.2,
output: 4.5,
cacheRead: 0.2,
cacheWrite: 0,
});
expect(model.compat).toEqual({
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "together",
supportsStrictMode: false,
supportsLongCacheRetention: false,
});
});
it("models Together reasoning controls from the Together API surface", () => {
const gptOss = getModel("together", "openai/gpt-oss-120b");
expect(gptOss.thinkingLevelMap).toEqual({ off: null, minimal: null });
expect(gptOss.compat).toMatchObject({
supportsReasoningEffort: true,
thinkingFormat: "openai",
});
const deepSeekV4 = getModel("together", "deepseek-ai/DeepSeek-V4-Pro");
expect(deepSeekV4.thinkingLevelMap).toEqual({
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: null,
});
expect(deepSeekV4.compat).toMatchObject({
supportsReasoningEffort: true,
thinkingFormat: "together",
});
const minimax = getModel("together", "MiniMaxAI/MiniMax-M2.7");
expect(minimax.thinkingLevelMap).toEqual({ off: null, minimal: null, low: null, medium: null });
expect(minimax.compat?.thinkingFormat).toBeUndefined();
expect(minimax.compat?.supportsReasoningEffort).toBe(false);
});
it("resolves TOGETHER_API_KEY from the environment", () => {
process.env.TOGETHER_API_KEY = "test-together-key";
expect(findEnvKeys("together")).toEqual(["TOGETHER_API_KEY"]);
expect(getEnvApiKey("together")).toBe("test-together-key");
});
});

View File

@@ -180,6 +180,14 @@ describe("Token Statistics on Abort", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
const llm = getModel("zai", "glm-4.5-air");

View File

@@ -191,6 +191,14 @@ describe("Tool Call Without Result Tests", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider", () => {
const model = getModel("together", "moonshotai/Kimi-K2.6");
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider", () => {
const model = getModel("zai", "glm-4.5-air");

View File

@@ -374,6 +374,28 @@ describe("totalTokens field", () => {
});
});
// =========================================================================
// Together AI
// =========================================================================
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI", () => {
it("Kimi-K2.6 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
console.log(`\nTogether AI / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, {
apiKey: process.env.TOGETHER_API_KEY,
reasoningEffort: "high",
});
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
});
});
// =========================================================================
// z.ai
// =========================================================================

View File

@@ -546,6 +546,23 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
});
});
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Unicode Handling", () => {
const llm = getModel("together", "moonshotai/Kimi-K2.6");
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm, options);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm, options);
});
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
await testUnpairedHighSurrogate(llm, options);
});
});
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Unicode Handling", () => {
const llm = getModel("zai", "glm-4.5-air");