From 7adb8e7634ccaee0510698ddf7df5362667db14c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 8 May 2026 16:44:18 +0200 Subject: [PATCH] feat(ai): add Together AI provider --- AGENTS.md | 2 +- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 8 +- packages/ai/scripts/generate-models.ts | 103 +++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 364 ++++++++++++++++-- .../ai/src/providers/openai-completions.ts | 28 +- packages/ai/src/types.ts | 5 +- packages/ai/src/utils/overflow.ts | 3 + packages/ai/test/abort.test.ts | 12 + packages/ai/test/context-overflow.test.ts | 16 + .../ai/test/cross-provider-handoff.test.ts | 2 + packages/ai/test/empty.test.ts | 20 + packages/ai/test/image-tool-result.test.ts | 13 + packages/ai/test/overflow.test.ts | 7 + packages/ai/test/stream.test.ts | 28 ++ packages/ai/test/together-models.test.ts | 78 ++++ packages/ai/test/tokens.test.ts | 8 + .../ai/test/tool-call-without-result.test.ts | 8 + packages/ai/test/total-tokens.test.ts | 22 ++ packages/ai/test/unicode-surrogate.test.ts | 17 + packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/custom-provider.md | 6 +- packages/coding-agent/docs/models.md | 4 +- packages/coding-agent/docs/providers.md | 2 + packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/model-registry.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-display-names.ts | 1 + 30 files changed, 714 insertions(+), 56 deletions(-) create mode 100644 packages/ai/test/together-models.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6c064ac7..f9205e03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ Create provider file exporting: ### 5. Tests (`packages/ai/test/`) - Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`. -- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `image-limits.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. +- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. - For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family. - For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index de94a9c3..67c402e6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Together AI as a built-in OpenAI-compatible provider with generated model metadata and `TOGETHER_API_KEY` authentication ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)). + ### Fixed - Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off. diff --git a/packages/ai/README.md b/packages/ai/README.md index 5d35f217..e88de28e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -66,6 +66,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **OpenRouter** - **Vercel AI Gateway** - **MiniMax** +- **Together AI** - **GitHub Copilot** (requires OAuth, see below) - **Amazon Bedrock** - **OpenCode Zen** @@ -800,7 +801,7 @@ A **provider** offers models through a specific API. For example: - **Google** models use the `google-generative-ai` API - **OpenAI** models use the `openai-responses` API - **Mistral** models use the `mistral-conversations` API -- **xAI, Cerebras, Groq, etc.** models use the `openai-completions` API (OpenAI-compatible) +- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible) ### Querying Providers and Models @@ -922,7 +923,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -937,7 +938,7 @@ interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) - thinkingFormat?: 'openai' | 'deepseek' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'deepseek' uses thinking: { type } plus reasoning_effort, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) @@ -1110,6 +1111,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Cloudflare Workers AI | `CLOUDFLARE_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | | xAI | `XAI_API_KEY` | | Fireworks | `FIREWORKS_API_KEY` | +| Together AI | `TOGETHER_API_KEY` | | OpenRouter | `OPENROUTER_API_KEY` | | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | | zAI | `ZAI_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 7ca0e410..e1d5c3ed 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -69,6 +69,58 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; +const TOGETHER_BASE_URL = "https://api.together.ai/v1"; +const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, +}; +const TOGETHER_TOGGLE_REASONING_COMPAT: OpenAICompletionsCompat = { + ...TOGETHER_BASE_COMPAT, + thinkingFormat: "together", +}; +const TOGETHER_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { + ...TOGETHER_BASE_COMPAT, + supportsReasoningEffort: true, + thinkingFormat: "openai", +}; +const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { + ...TOGETHER_TOGGLE_REASONING_COMPAT, + supportsReasoningEffort: true, +}; +const TOGETHER_REASONING_ONLY_MODELS = new Set([ + "deepseek-ai/DeepSeek-R1", + "MiniMaxAI/MiniMax-M2.5", + "MiniMaxAI/MiniMax-M2.7", +]); +const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); +const TOGETHER_TOGGLE_REASONING_EFFORT_MODELS = new Set(["deepseek-ai/DeepSeek-V4-Pro"]); +const TOGETHER_FIXED_REASONING_LEVEL_MAP = { + off: null, + minimal: null, + low: null, + medium: null, +} as const; +const TOGETHER_REASONING_EFFORT_LEVEL_MAP = { + off: null, + minimal: null, +} as const; +const TOGETHER_DEEPSEEK_V4_THINKING_LEVEL_MAP = { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, +} as const; +const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = { + minimal: null, + low: null, + medium: null, +} as const; + const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); @@ -100,6 +152,25 @@ function mergeThinkingLevelMap(model: Model, map: NonNullable["t model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map }; } +function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat { + if (!reasoning) return TOGETHER_BASE_COMPAT; + if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT; + if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT; + if (TOGETHER_REASONING_ONLY_MODELS.has(modelId)) return TOGETHER_BASE_COMPAT; + return TOGETHER_TOGGLE_REASONING_COMPAT; +} + +function getTogetherThinkingLevelMap( + modelId: string, + reasoning: boolean, +): NonNullable["thinkingLevelMap"]> | undefined { + if (!reasoning) return undefined; + if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_REASONING_EFFORT_LEVEL_MAP }; + if (TOGETHER_TOGGLE_REASONING_EFFORT_MODELS.has(modelId)) return { ...TOGETHER_DEEPSEEK_V4_THINKING_LEVEL_MAP }; + if (TOGETHER_REASONING_ONLY_MODELS.has(modelId)) return { ...TOGETHER_FIXED_REASONING_LEVEL_MAP }; + return { ...TOGETHER_TOGGLE_REASONING_LEVEL_MAP }; +} + function supportsOpenAiXhigh(modelId: string): boolean { return ( modelId.includes("gpt-5.2") || @@ -697,6 +768,38 @@ async function loadModelsDevData(): Promise[]> { } } + // Process Together AI models + const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"]; + if (togetherProvider?.models) { + for (const [modelId, model] of Object.entries(togetherProvider.models)) { + const m = model as ModelsDevModel & { status?: string }; + if (m.tool_call !== true) continue; + if (m.status === "deprecated") continue; + + const reasoning = m.reasoning === true; + const thinkingLevelMap = getTogetherThinkingLevelMap(modelId, reasoning); + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider: "together", + baseUrl: TOGETHER_BASE_URL, + reasoning, + ...(thinkingLevelMap ? { thinkingLevelMap } : {}), + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + compat: getTogetherCompat(modelId, reasoning), + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + // Process OpenCode models (Zen and Go) // API mapping based on provider.npm field: // - @ai-sdk/openai → openai-responses diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index b5b2ea81..846623aa 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -117,6 +117,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "moonshotai-cn": "MOONSHOT_API_KEY", huggingface: "HF_TOKEN", fireworks: "FIREWORKS_API_KEY", + together: "TOGETHER_API_KEY", opencode: "OPENCODE_API_KEY", "opencode-go": "OPENCODE_API_KEY", "kimi-coding": "KIMI_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9ad5d97b..e700a56c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -10188,23 +10188,6 @@ export const MODELS = { contextWindow: 65536, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mixtral-8x7b-instruct": { - id: "mistralai/mixtral-8x7b-instruct", - name: "Mistral: Mixtral 8x7B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.54, - output: 0.54, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/pixtral-large-2411": { id: "mistralai/pixtral-large-2411", name: "Mistral: Pixtral Large 2411", @@ -10341,23 +10324,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 163840, } satisfies Model<"openai-completions">, - "nvidia/llama-3.1-nemotron-70b-instruct": { - id: "nvidia/llama-3.1-nemotron-70b-instruct", - name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.2, - output: 1.2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "nvidia/llama-3.3-nemotron-super-49b-v1.5": { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", @@ -12963,13 +12929,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.38, - output: 1.74, - cacheRead: 0, + input: 0.39999999999999997, + output: 1.75, + cacheRead: 0.08, cacheWrite: 0, }, contextWindow: 202752, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-4.7-flash": { id: "z-ai/glm-4.7-flash", @@ -13193,6 +13159,328 @@ export const MODELS = { maxTokens: 128000, } satisfies Model<"openai-completions">, }, + "together": { + "MiniMaxAI/MiniMax-M2.5": { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax-M2.5", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMaxAI/MiniMax-M2.7": { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + name: "Qwen3 235B A22B Instruct 2507 FP8", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.2, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + name: "Qwen3 Coder 480B A35B Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 2, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-Coder-Next-FP8": { + id: "Qwen/Qwen3-Coder-Next-FP8", + name: "Qwen3 Coder Next FP8", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.5, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.5-397B-A17B": { + id: "Qwen/Qwen3.5-397B-A17B", + name: "Qwen3.5 397B A17B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 130000, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.6-Plus": { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen3.6 Plus", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3": { + id: "deepseek-ai/DeepSeek-V3", + name: "DeepSeek V3", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1.25, + output: 1.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3-1": { + id: "deepseek-ai/DeepSeek-V3-1", + name: "DeepSeek V3.1", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.6, + output: 1.7, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V4-Pro": { + id: "deepseek-ai/DeepSeek-V4-Pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null}, + input: ["text"], + cost: { + input: 2.1, + output: 4.4, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "essentialai/Rnj-1-Instruct": { + id: "essentialai/Rnj-1-Instruct", + name: "Rnj-1 Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32768, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "google/gemma-4-31B-it": { + id: "google/gemma-4-31B-it", + name: "Gemma 4 31B Instruct", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "meta-llama/Llama-3.3-70B-Instruct-Turbo": { + id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", + name: "Llama 3.3 70B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.88, + output: 0.88, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.5": { + id: "moonshotai/Kimi-K2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 2.8, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "moonshotai/Kimi-K2.6": { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 1.2, + output: 4.5, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131000, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b": { + id: "openai/gpt-oss-120b", + name: "GPT OSS 120B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"openai"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null}, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "zai-org/GLM-5.1": { + id: "zai-org/GLM-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 202752, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + }, "vercel-ai-gateway": { "alibaba/qwen-3-14b": { id: "alibaba/qwen-3-14b", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 4f001514..01bc6e78 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -575,6 +575,15 @@ function buildParams( } else if (model.thinkingLevelMap?.off !== null) { openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; } + } else if (compat.thinkingFormat === "together" && model.reasoning) { + const togetherParams = params as Omit & { + reasoning?: { enabled: boolean }; + reasoning_effort?: string; + }; + togetherParams.reasoning = { enabled: !!options?.reasoningEffort }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; @@ -1050,6 +1059,8 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const baseUrl = model.baseUrl; const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); + const isTogether = + provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); @@ -1059,6 +1070,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet baseUrl.includes("cerebras.ai") || provider === "xai" || baseUrl.includes("api.x.ai") || + isTogether || baseUrl.includes("chutes.ai") || baseUrl.includes("deepseek.com") || isZai || @@ -1068,7 +1080,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isCloudflareWorkersAI || isCloudflareAiGateway; - const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway; + const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); @@ -1077,7 +1089,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, supportsDeveloperRole: !isNonStandard, - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isCloudflareAiGateway, + supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1088,16 +1100,18 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet ? "deepseek" : isZai ? "zai" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") - ? "openrouter" - : "openai", + : isTogether + ? "together" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, zaiToolStream: false, - supportsStrictMode: !isMoonshot && !isCloudflareAiGateway, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway, cacheControlFormat, sendSessionAffinityHeaders: false, - supportsLongCacheRetention: !(isCloudflareWorkersAI || isCloudflareAiGateway), + supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway), }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index db6ef893..ec929ea6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -43,6 +43,7 @@ export type KnownProvider = | "moonshotai-cn" | "huggingface" | "fireworks" + | "together" | "opencode" | "opencode-go" | "kimi-coding" @@ -380,8 +381,8 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "openrouter" | "deepseek" | "zai" | "qwen" | "qwen-chat-template"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index b06a3fd1..bf879dc4 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -15,6 +15,7 @@ import type { AssistantMessage } from "../types.js"; * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" * - Groq: "Please reduce the length of the messages or completion" * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "the request exceeds the available context size, try increasing it" * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" @@ -37,6 +38,7 @@ const OVERFLOW_PATTERNS = [ /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI /exceeds the limit of \d+/i, // GitHub Copilot /exceeds the available context size/i, // llama.cpp server /greater than the context length/i, // LM Studio @@ -86,6 +88,7 @@ const NON_OVERFLOW_PATTERNS = [ * - Cerebras: 400/413 status code (no body) * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" * - OpenRouter (all backends): "maximum context length is X tokens" + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." * - llama.cpp: "exceeds the available context size" * - LM Studio: "greater than the context length" * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 57ef078a..57bf39d1 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -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"); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 9a62453b..56d6620d 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -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, diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 542d2345..34325f89 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -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 diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 560468bd..b14e9e55 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -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"); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 48f62aa4..0477b162 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -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"); diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index 633ba663..1b50b831 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -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); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 910c1a02..68d2810c 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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"); diff --git a/packages/ai/test/together-models.test.ts b/packages/ai/test/together-models.test.ts new file mode 100644 index 00000000..14031447 --- /dev/null +++ b/packages/ai/test/together-models.test.ts @@ -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"); + }); +}); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index eca33524..a63d9321 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -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"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 532a3760..3f375e47 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -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"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 658b6aeb..3e956ff0 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -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 // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index bf044f35..75fbf19a 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -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"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b4ff2d74..3136e113 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Together AI to built-in provider setup, `/login` API-key auth, and default model resolution ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)). + ### Fixed - Fixed keybinding hints to show Option instead of Alt on macOS ([#4289](https://github.com/earendil-works/pi/issues/4289)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0bc1dde8..44e1bce1 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -127,6 +127,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - OpenCode Go - Hugging Face - Fireworks +- Together AI - Kimi For Coding - MiniMax - Xiaomi MiMo diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 095bc1ca..be80ade4 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -226,7 +226,7 @@ models: [{ }] ``` -Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. @@ -636,11 +636,11 @@ interface ProviderModelConfig { requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; - thinkingFormat?: "openai" | "deepseek" | "zai" | "qwen" | "qwen-chat-template"; + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; cacheControlFormat?: "anthropic"; }; } ``` -`deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 0bc41643..12636700 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -381,14 +381,14 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | -| `thinkingFormat` | Use `reasoning_effort`, `deepseek`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | +| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | | `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | -`qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`. +`openrouter` uses `reasoning: { effort }`. `together` uses `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` uses top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that require `chat_template_kwargs.enable_thinking`. `cacheControlFormat: "anthropic"` is for OpenAI-compatible providers that expose Anthropic-style prompt caching through `cache_control` markers on text content and tool definitions. diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 3e3e4cb8..7e80f931 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -66,6 +66,7 @@ pi | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | | Hugging Face | `HF_TOKEN` | `huggingface` | | Fireworks | `FIREWORKS_API_KEY` | `fireworks` | +| Together AI | `TOGETHER_API_KEY` | `together` | | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | @@ -88,6 +89,7 @@ Store credentials in `~/.pi/agent/auth.json`: "google": { "type": "api_key", "key": "..." }, "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, + "together": { "type": "api_key", "key": "..." }, "xiaomi": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-ams": { "type": "api_key", "key": "..." }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d673b395..7d91a9be 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -314,6 +314,7 @@ ${chalk.bold("Environment Variables:")} CEREBRAS_API_KEY - Cerebras API key XAI_API_KEY - xAI Grok API key FIREWORKS_API_KEY - Fireworks API key + TOGETHER_API_KEY - Together AI API key OPENROUTER_API_KEY - OpenRouter API key AI_GATEWAY_API_KEY - Vercel AI Gateway API key ZAI_API_KEY - ZAI API key diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 0ad16745..a30ee210 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -105,6 +105,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ Type.Union([ Type.Literal("openai"), Type.Literal("openrouter"), + Type.Literal("together"), Type.Literal("deepseek"), Type.Literal("zai"), Type.Literal("qwen"), diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 02895055..18a3eed8 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -34,6 +34,7 @@ export const defaultModelPerProvider: Record = { "moonshotai-cn": "kimi-k2.6", huggingface: "moonshotai/Kimi-K2.6", fireworks: "accounts/fireworks/models/kimi-k2p6", + together: "moonshotai/Kimi-K2.6", opencode: "kimi-k2.6", "opencode-go": "kimi-k2.6", "kimi-coding": "kimi-for-coding", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 6a5f0e2a..24f7b3ce 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -21,6 +21,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "opencode-go": "OpenCode Go", openai: "OpenAI", openrouter: "OpenRouter", + together: "Together AI", "vercel-ai-gateway": "Vercel AI Gateway", xai: "xAI", zai: "ZAI",