From 84f017c98e0fb725b537b5de87fa24c0a9d9cf5b Mon Sep 17 00:00:00 2001 From: brownrw8 Date: Fri, 7 Feb 2025 11:55:48 -1000 Subject: [PATCH] feat:Add Together API Provider (#1698) --- .changeset/green-oranges-sit.md | 5 ++ src/api/index.ts | 3 + src/api/providers/together.ts | 75 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 13 ++++ src/shared/api.ts | 3 + .../src/components/settings/ApiOptions.tsx | 34 ++++++++- .../settings/__tests__/APIOptions.spec.tsx | 49 +++++++++++- .../src/context/ExtensionStateContext.tsx | 1 + webview-ui/src/utils/validate.ts | 5 ++ 9 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 .changeset/green-oranges-sit.md create mode 100644 src/api/providers/together.ts diff --git a/.changeset/green-oranges-sit.md b/.changeset/green-oranges-sit.md new file mode 100644 index 0000000000..4d2bfebfd4 --- /dev/null +++ b/.changeset/green-oranges-sit.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Add Together API Provider diff --git a/src/api/index.ts b/src/api/index.ts index 09598680c7..4681ec03af 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,6 +12,7 @@ import { OpenAiNativeHandler } from "./providers/openai-native" import { ApiStream } from "./transform/stream" import { DeepSeekHandler } from "./providers/deepseek" import { RequestyHandler } from "./providers/requesty" +import { TogetherHandler } from "./providers/together" import { QwenHandler } from "./providers/qwen" import { MistralHandler } from "./providers/mistral" import { VsCodeLmHandler } from "./providers/vscode-lm" @@ -51,6 +52,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new DeepSeekHandler(options) case "requesty": return new RequestyHandler(options) + case "together": + return new TogetherHandler(options) case "qwen": return new QwenHandler(options) case "mistral": diff --git a/src/api/providers/together.ts b/src/api/providers/together.ts new file mode 100644 index 0000000000..38ae26dd5b --- /dev/null +++ b/src/api/providers/together.ts @@ -0,0 +1,75 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import { withRetry } from "../retry" +import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" +import { ApiHandler } from "../index" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream } from "../transform/stream" +import { convertToR1Format } from "../transform/r1-format" + +export class TogetherHandler implements ApiHandler { + private options: ApiHandlerOptions + private client: OpenAI + + constructor(options: ApiHandlerOptions) { + this.options = options + this.client = new OpenAI({ + baseURL: "https://api.together.xyz/v1", + apiKey: this.options.togetherApiKey, + }) + } + + @withRetry() + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const modelId = this.options.togetherModelId ?? "" + const isDeepseekReasoner = modelId.includes("deepseek-reasoner") + + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + if (isDeepseekReasoner) { + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + } + + const stream = await this.client.chat.completions.create({ + model: modelId, + messages: openAiMessages, + temperature: 0, + stream: true, + stream_options: { include_usage: true }, + }) + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + + if (delta && "reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + reasoning: (delta.reasoning_content as string | undefined) || "", + } + } + + if (chunk.usage) { + yield { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + } + } + } + } + + getModel(): { id: string; info: ModelInfo } { + return { + id: this.options.togetherModelId ?? "", + info: openAiModelInfoSaneDefaults, + } + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 97ab605228..142ff23a1b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -46,6 +46,7 @@ type SecretKey = | "openAiNativeApiKey" | "deepSeekApiKey" | "requestyApiKey" + | "togetherApiKey" | "qwenApiKey" | "mistralApiKey" | "authToken" @@ -84,6 +85,7 @@ type GlobalStateKey = | "liteLlmModelId" | "qwenApiLine" | "requestyModelId" + | "togetherModelId" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -450,6 +452,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { deepSeekApiKey, requestyApiKey, requestyModelId, + togetherApiKey, + togetherModelId, qwenApiKey, mistralApiKey, azureApiVersion, @@ -485,6 +489,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey) await this.storeSecret("deepSeekApiKey", deepSeekApiKey) await this.storeSecret("requestyApiKey", requestyApiKey) + await this.storeSecret("togetherApiKey", togetherApiKey) await this.storeSecret("qwenApiKey", qwenApiKey) await this.storeSecret("mistralApiKey", mistralApiKey) await this.updateGlobalState("azureApiVersion", azureApiVersion) @@ -495,6 +500,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("liteLlmModelId", liteLlmModelId) await this.updateGlobalState("qwenApiLine", qwenApiLine) await this.updateGlobalState("requestyModelId", requestyModelId) + await this.updateGlobalState("togetherModelId", togetherModelId) if (this.cline) { this.cline.api = buildApiHandler(message.apiConfiguration) } @@ -1387,6 +1393,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { deepSeekApiKey, requestyApiKey, requestyModelId, + togetherApiKey, + togetherModelId, qwenApiKey, mistralApiKey, azureApiVersion, @@ -1434,6 +1442,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getSecret("deepSeekApiKey") as Promise, this.getSecret("requestyApiKey") as Promise, this.getGlobalState("requestyModelId") as Promise, + this.getSecret("togetherApiKey") as Promise, + this.getGlobalState("togetherModelId") as Promise, this.getSecret("qwenApiKey") as Promise, this.getSecret("mistralApiKey") as Promise, this.getGlobalState("azureApiVersion") as Promise, @@ -1498,6 +1508,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { deepSeekApiKey, requestyApiKey, requestyModelId, + togetherApiKey, + togetherModelId, qwenApiKey, qwenApiLine, mistralApiKey, @@ -1596,6 +1608,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "openAiNativeApiKey", "deepSeekApiKey", "requestyApiKey", + "togetherApiKey", "qwenApiKey", "mistralApiKey", "authToken", diff --git a/src/shared/api.ts b/src/shared/api.ts index 748f1a7b9d..169be3640a 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -9,6 +9,7 @@ export type ApiProvider = | "gemini" | "openai-native" | "requesty" + | "together" | "deepseek" | "qwen" | "mistral" @@ -45,6 +46,8 @@ export interface ApiHandlerOptions { deepSeekApiKey?: string requestyApiKey?: string requestyModelId?: string + togetherApiKey?: string + togetherModelId?: string qwenApiKey?: string mistralApiKey?: string azureApiVersion?: string diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index d11fb5b0d3..f5f69517c3 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -188,6 +188,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is OpenAI OpenAI Compatible Requesty + Together VS Code LM API LM Studio Ollama @@ -316,7 +317,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is {selectedProvider === "qwen" && (
- + @@ -740,6 +741,37 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
)} + {selectedProvider === "together" && ( +
+ + API Key + + + Model ID + +

+ + (Note: Cline uses complex prompts and works best with Claude + models. Less capable models may not work as expected.) + +

+
+ )} + {selectedProvider === "vscode-lm" && (
diff --git a/webview-ui/src/components/settings/__tests__/APIOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/APIOptions.spec.tsx index 47c662acfe..5a074d43b8 100644 --- a/webview-ui/src/components/settings/__tests__/APIOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/APIOptions.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import { describe, it, expect, vi } from "vitest" import ApiOptions from "../ApiOptions" import { ExtensionStateContextProvider } from "../../../context/ExtensionStateContext" @@ -23,7 +23,6 @@ vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => { describe("ApiOptions Component", () => { vi.clearAllMocks() const mockPostMessage = vi.fn() - const mockSetApiConfiguration = vi.fn() beforeEach(() => { global.vscode = { postMessage: mockPostMessage } as any @@ -49,3 +48,49 @@ describe("ApiOptions Component", () => { expect(modelIdInput).toBeInTheDocument() }) }) + +vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // your mocked methods + useExtensionState: vi.fn(() => ({ + apiConfiguration: { + apiProvider: "together", + requestyApiKey: "", + requestyModelId: "", + }, + setApiConfiguration: vi.fn(), + uriScheme: "vscode", + })), + } +}) + +describe("ApiOptions Component", () => { + vi.clearAllMocks() + const mockPostMessage = vi.fn() + + beforeEach(() => { + global.vscode = { postMessage: mockPostMessage } as any + }) + + it("renders Together API Key input", () => { + render( + + + , + ) + const apiKeyInput = screen.getByPlaceholderText("Enter API Key...") + expect(apiKeyInput).toBeInTheDocument() + }) + + it("renders Together Model ID input", () => { + render( + + + , + ) + const modelIdInput = screen.getByPlaceholderText("Enter Model ID...") + expect(modelIdInput).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 34abc21619..a02b6f121a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -68,6 +68,7 @@ export const ExtensionStateContextProvider: React.FC<{ config.openAiNativeApiKey, config.deepSeekApiKey, config.requestyApiKey, + config.togetherApiKey, config.qwenApiKey, config.mistralApiKey, config.vsCodeLmModelSelector, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 698a778f3c..faa0434814 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -58,6 +58,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s return "You must provide a valid API key or choose a different provider." } break + case "together": + if (!apiConfiguration.togetherApiKey || !apiConfiguration.togetherModelId) { + return "You must provide a valid API key or choose a different provider." + } + break case "ollama": if (!apiConfiguration.ollamaModelId) { return "You must provide a valid model ID."