diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 30789c8..a1b6533 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,65 +1,43 @@ -name: .NET +name: .NET CI on: push: branches: [ main ] pull_request: branches: [ main ] - - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: - build-and-test: runs-on: ubuntu-latest - + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore Together.slnx - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 9.0.x - - # run build and test - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore --configuration Release - - - name: Test and Collect Code Coverage - run: dotnet test --configuration Release -p:CollectCoverage=true -p:CoverletOutput=coverage/ + - name: Build + run: dotnet build Together.slnx --configuration Release --no-restore - - name: Copy coverage files - run: | - mkdir '${{ github.workspace }}/coverage' - find . -name "*.opencover.xml" -exec sh -c 'cp "$0" "coverage/coverage-$(basename $0)"' {} \; + - name: Test and collect coverage + run: dotnet test Together.slnx --configuration Release --no-build -p:CollectCoverage=true -p:CoverletOutput=coverage/ - - name: List coverage files - run: ls '${{ github.workspace }}/coverage/' + - name: Copy coverage files + run: | + mkdir -p "${{ github.workspace }}/coverage" + find . -name "*.opencover.xml" -exec sh -c 'cp "$1" "coverage/coverage-$(basename "$1")"' _ {} \; - - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master - with: - args: > - -Dsonar.organization=managedcode - -Dsonar.projectKey=managedcode_Together - -Dsonar.token=${{ secrets.SONAR_TOKEN }} - -Dsonar.cs.opencover.reportsPaths=${{ github.workspace }}/coverage/ - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - name: List coverage files + run: ls "${{ github.workspace }}/coverage/" - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - -# - name: coveralls -# uses: coverallsapp/github-action@master -# with: -# github-token: ${{secrets.GITHUB_TOKEN }} -# path-to-lcov: coverage/coverage.info + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/monitor-upstream.yml b/.github/workflows/monitor-upstream.yml new file mode 100644 index 0000000..0c59305 --- /dev/null +++ b/.github/workflows/monitor-upstream.yml @@ -0,0 +1,73 @@ +name: Monitor upstream SDKs + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +jobs: + check-updates: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + + steps: + - name: Check upstream repositories + id: check + uses: actions/github-script@v7 + with: + script: | + const repos = [ + { owner: 'togethercomputer', repo: 'together-python' }, + { owner: 'togethercomputer', repo: 'together-typescript' } + ]; + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); + const results = []; + for (const target of repos) { + const { data } = await github.repos.listCommits({ owner: target.owner, repo: target.repo, per_page: 10 }); + const recent = data.filter(c => new Date(c.commit.committer.date) > cutoff); + if (recent.length > 0) { + results.push({ + owner: target.owner, + repo: target.repo, + commits: recent.map(c => ({ + sha: c.sha.substring(0, 7), + message: c.commit.message.split('\n')[0], + url: c.html_url, + date: c.commit.committer.date + })) + }); + } + } + core.setOutput('updates', JSON.stringify(results)); + core.info(`Found ${results.length} repositories with updates.`); + + - name: Create tracking issue + if: steps.check.outputs.updates != '[]' + uses: actions/github-script@v7 + env: + UPDATES: ${{ steps.check.outputs.updates }} + with: + script: | + const updates = JSON.parse(process.env.UPDATES ?? '[]'); + const today = new Date().toISOString().split('T')[0]; + let body = 'Detected new upstream commits:\n\n'; + for (const repo of updates) { + body += `### ${repo.owner}/${repo.repo}\n`; + for (const commit of repo.commits) { + body += `- [${commit.sha}](${commit.url}) ${commit.message} (_${commit.date}_)\n`; + } + body += '\n'; + } + await github.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Upstream SDK updates ${today}`, + body, + labels: ['upstream-monitor'] + }); + + - name: No updates found + if: steps.check.outputs.updates == '[]' + run: echo "No upstream changes detected in the last 24 hours." diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml deleted file mode 100644 index 22a4023..0000000 --- a/.github/workflows/nuget.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: nuget - -on: - push: - branches: [ main ] - -jobs: - nuget-pack: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release - - - name: Test - run: dotnet test --configuration Release - - - name: Pack - run: dotnet pack --configuration Release -p:IncludeSymbols=false -p:SymbolPackageFormat=snupkg -o "packages" - - - name: Push - run: dotnet nuget push "packages/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a30797 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,233 @@ +name: Release + +on: + push: + branches: [ main ] + workflow_dispatch: + +env: + DOTNET_VERSION: '9.0.x' + SOLUTION_PATH: Together.slnx + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Extract version from Directory.Build.props + id: version + run: | + VERSION=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version from Directory.Build.props: $VERSION" + + - name: Restore dependencies + run: dotnet restore ${{ env.SOLUTION_PATH }} + + - name: Build + run: dotnet build ${{ env.SOLUTION_PATH }} --configuration Release --no-restore + + - name: Test + run: dotnet test ${{ env.SOLUTION_PATH }} --configuration Release --no-build --verbosity normal + + - name: Pack NuGet packages + run: dotnet pack ${{ env.SOLUTION_PATH }} --configuration Release --no-build --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 5 + + publish-nuget: + name: Publish to NuGet + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + outputs: + published: ${{ steps.publish.outputs.published }} + version: ${{ needs.build.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download artifacts + uses: actions/download-artifact@v5 + with: + name: nuget-packages + path: ./artifacts + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Publish to NuGet + id: publish + continue-on-error: true + run: | + set +e + shopt -s nullglob + OUTPUT="" + PUBLISHED=false + + for package in ./artifacts/*.nupkg; do + echo "Publishing $package..." + RESULT=$(dotnet nuget push "$package" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate 2>&1) + EXIT_CODE=$? + echo "$RESULT" + OUTPUT="$OUTPUT$RESULT" + + if [ $EXIT_CODE -eq 0 ]; then + echo "Successfully published $package" + PUBLISHED=true + elif echo "$RESULT" | grep -qi "already exists"; then + echo "Package already exists, skipping..." + else + echo "Failed to publish $package" + exit 1 + fi + done + + if [ "$PUBLISHED" = true ] || echo "$OUTPUT" | grep -q "Your package was pushed"; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "At least one package was successfully published" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "No new packages were published (all already exist)" + fi + + create-release: + name: Create GitHub Release and Tag + needs: publish-nuget + runs-on: ubuntu-latest + if: needs.publish-nuget.outputs.published == 'true' + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download artifacts + uses: actions/download-artifact@v5 + with: + name: nuget-packages + path: ./artifacts + + - name: Create and push tag + id: create_tag + run: | + VERSION="${{ needs.publish-nuget.outputs.version }}" + TAG="v$VERSION" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + echo "tag_exists=true" >> "$GITHUB_OUTPUT" + else + echo "Creating tag $TAG" + git tag -a "$TAG" -m "Release $VERSION" + git push origin "$TAG" + echo "tag_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Get previous tag + id: prev_tag + run: | + CURRENT_TAG="v${{ needs.publish-nuget.outputs.version }}" + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -A1 "^$CURRENT_TAG$" | tail -n1 || echo "") + if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -n1 || echo "") + fi + echo "previous_tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" + echo "Current tag: $CURRENT_TAG" + echo "Previous tag: $PREVIOUS_TAG" + + - name: Generate release notes + id: release_notes + run: | + VERSION="${{ needs.publish-nuget.outputs.version }}" + CURRENT_TAG="v$VERSION" + PREVIOUS_TAG="${{ steps.prev_tag.outputs.previous_tag }}" + + echo "# Release $VERSION" > release_notes.md + echo "" >> release_notes.md + echo "Released on $(date +'%Y-%m-%d')" >> release_notes.md + echo "" >> release_notes.md + + if [ -n "$PREVIOUS_TAG" ]; then + echo "## 📋 Changes since $PREVIOUS_TAG" >> release_notes.md + echo "" >> release_notes.md + + echo "### ✨ Features" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^feat" --grep="^feature" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### 🐛 Bug Fixes" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^fix" --grep="^bugfix" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### 📚 Documentation" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^docs" --grep="^doc" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### 🔧 Other Changes" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" >> release_notes.md || true + echo "" >> release_notes.md + else + echo "## 🎉 Initial Release" >> release_notes.md + echo "" >> release_notes.md + echo "### Recent Changes" >> release_notes.md + git log --pretty=format:"- %s (%h)" --max-count=20 >> release_notes.md || true + echo "" >> release_notes.md + fi + + echo "" >> release_notes.md + echo "## 📦 NuGet Packages" >> release_notes.md + echo "" >> release_notes.md + shopt -s nullglob + for package in ./artifacts/*.nupkg; do + PACKAGE_NAME=$(basename "$package" .nupkg) + BASE_NAME=$(echo "$PACKAGE_NAME" | sed "s/\.$VERSION//") + echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" >> release_notes.md + done + if ! ls ./artifacts/*.nupkg >/dev/null 2>&1; then + echo "- _No packages produced_" >> release_notes.md + fi + + echo "" >> release_notes.md + echo "---" >> release_notes.md + echo "*This release was automatically created by GitHub Actions*" >> release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.publish-nuget.outputs.version }} + name: v${{ needs.publish-nuget.outputs.version }} + body_path: release_notes.md + draft: false + prerelease: false + files: ./artifacts/*.nupkg + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Directory.Build.props b/Directory.Build.props index 878f7a8..98b401f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,7 @@ 13 true enable + true diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..b4abd72 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Together.SemanticKernel/Together.SemanticKernel.csproj b/Together.SemanticKernel/Together.SemanticKernel.csproj index 528dd48..8f61ef1 100644 --- a/Together.SemanticKernel/Together.SemanticKernel.csproj +++ b/Together.SemanticKernel/Together.SemanticKernel.csproj @@ -9,7 +9,7 @@ - + diff --git a/Together.Tests/Clients/NewResourceClientTests.cs b/Together.Tests/Clients/NewResourceClientTests.cs new file mode 100644 index 0000000..31ec838 --- /dev/null +++ b/Together.Tests/Clients/NewResourceClientTests.cs @@ -0,0 +1,205 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using Together.Clients; +using Together.Models.Audio; +using Together.Models.Batch; +using Together.Models.CodeInterpreter; +using Together.Models.Evaluations; +using Together.Models.Videos; + +namespace Together.Tests.Clients; + +public class NewResourceClientTests : TestBase +{ + [Fact] + public async Task BatchClient_CreateAsync_ReturnsJob() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"job":{"id":"batch_1","input_file_id":"file_1","user_id":"user","file_size_bytes":10,"status":"IN_PROGRESS","job_deadline":"2024-01-01T00:00:00Z","created_at":"2024-01-01T00:00:00Z","endpoint":"/v1/completions","progress":0.5}}""") + }; + + var client = new BatchClient(CreateMockHttpClient(response)); + var result = await client.CreateAsync(new BatchCreateRequest { InputFileId = "file_1", Endpoint = "/v1/completions" }); + + Assert.Equal("batch_1", result.Id); + Assert.Equal("file_1", result.InputFileId); + } + + [Fact] + public async Task EndpointClient_ListAsync_ParsesResponse() + { + var json = """{"data":[{"id":"ep1","object":"endpoint","name":"ep","model":"model","type":"dedicated","owner":"user","state":"STARTED","created_at":"2024-01-01T00:00:00Z"}]}"""; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json) + }; + + var client = new EndpointClient(CreateMockHttpClient(response)); + var results = await client.ListAsync(); + + Assert.Single(results); + Assert.Equal("ep1", results[0].Id); + } + + [Fact] + public async Task HardwareClient_ListAsync_ReturnsHardware() + { + var json = """{"object":"list","data":[{"object":"hardware","id":"hw1","pricing":{"cents_per_minute":1},"specs":{"gpu_type":"A100","gpu_link":"NVLINK","gpu_memory":80,"gpu_count":1},"updated_at":"2024-01-01T00:00:00Z"}]}"""; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json) + }; + + var client = new HardwareClient(CreateMockHttpClient(response)); + var result = await client.ListAsync(); + + Assert.Single(result.Data); + Assert.Equal("hw1", result.Data[0].Id); + } + + [Fact] + public async Task JobClient_RetrieveAsync_ReturnsJob() + { + var json = """{"job_id":"job-1","args":{},"created_at":"2024-01-01","status":"Queued","status_updates":[],"type":"train","updated_at":"2024-01-01"}"""; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json) + }; + + var client = new JobClient(CreateMockHttpClient(response)); + var job = await client.RetrieveAsync("job-1"); + + Assert.Equal("job-1", job.JobId); + Assert.Equal("Queued", job.Status); + } + + [Fact] + public async Task EvaluationClient_CreateAsync_ReturnsWorkflow() + { + var json = """{"workflow_id":"wf_1","status":"queued"}"""; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json) + }; + + var client = new EvaluationClient(CreateMockHttpClient(response)); + var result = await client.CreateAsync(new EvaluationCreateRequest + { + Type = "classify", + Judge = new JudgeModelConfig { Model = "judge", ModelSource = "serverless", SystemTemplate = "template" }, + InputDataFilePath = "file.jsonl", + Labels = new List { "yes" }, + PassLabels = new List { "yes" } + }); + + Assert.Equal("wf_1", result.WorkflowId); + Assert.Equal("queued", result.Status); + } + + [Fact] + public async Task CodeInterpreterClient_RunAsync_ReturnsOutputs() + { + var json = """{"data":{"session_id":"sess","status":"completed","outputs":[{"type":"stdout","data":"hello"}]}}"""; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(json) + }; + + var client = new CodeInterpreterClient(CreateMockHttpClient(response)); + var result = await client.RunAsync(new CodeInterpreterRequest { Code = "print('hi')" }); + + Assert.Equal("sess", result.Data.SessionId); + Assert.Single(result.Data.Outputs); + } + + [Fact] + public async Task AudioClient_CreateSpeechAsync_ReturnsBytes() + { + var audioBytes = new byte[] { 1, 2, 3 }; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent(audioBytes) + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/wav"); + + var client = new AudioClient(CreateMockHttpClient(response)); + var result = await client.CreateSpeechAsync(new AudioSpeechRequest { Model = "model", Input = "hello" }); + + Assert.NotNull(result.Data); + Assert.Equal(audioBytes, result.Data); + } + + [Fact] + public async Task AudioClient_CreateSpeechAsync_ParsesStream() + { + var payload = "data: {\"b64\":\"AQI=\"}\n" + + "data: [DONE]\n"; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(payload) + }; + + var client = new AudioClient(CreateMockHttpClient(response)); + var request = new AudioSpeechRequest { Model = "model", Input = "hi", Stream = true }; + var stream = await client.CreateSpeechAsync(request); + + var chunks = new List(); + await foreach (var chunk in stream.Stream!) + { + chunks.Add(chunk); + } + + Assert.Single(chunks); + Assert.Equal(new byte[] { 1, 2 }, chunks[0]); + } + + [Fact] + public async Task AudioClient_CreateTranscriptionAsync_ReturnsText() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("""{"text":"Hello"}""") + }; + + var request = new AudioFileRequest + { + Content = new MemoryStream(Encoding.UTF8.GetBytes("data")), + FileName = "audio.wav", + Model = "whisper" + }; + + var client = new AudioClient(CreateMockHttpClient(response)); + var result = await client.CreateTranscriptionAsync(request); + + Assert.Equal("Hello", result.Response!.Text); + } + + [Fact] + public async Task VideoClient_CreateAsync_ReturnsId() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("""{"id":"video_1"}""") + }; + + var client = new VideoClient(CreateMockHttpClient(response)); + var result = await client.CreateAsync(new CreateVideoRequest { Model = "video-model" }); + + Assert.Equal("video_1", result.Id); + } +} diff --git a/Together.Tests/MicrosoftAI/MicrosoftAIAdapterTests.cs b/Together.Tests/MicrosoftAI/MicrosoftAIAdapterTests.cs new file mode 100644 index 0000000..d224273 --- /dev/null +++ b/Together.Tests/MicrosoftAI/MicrosoftAIAdapterTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using Microsoft.Extensions.AI; +using Together.Clients; +using Together.Extensions.MicrosoftAI; + +namespace Together.Tests.MicrosoftAI; + +public class MicrosoftAIAdapterTests : TestBase +{ + [Fact] + public async Task ChatAdapter_ConvertsResponse() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"id":"resp","model":"chat-model","choices":[{"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}""") + }; + + var adapter = new TogetherAIChatClient(new ChatCompletionClient(CreateMockHttpClient(response)), "chat-model"); + var chatResponse = await adapter.GetResponseAsync(new[] { new ChatMessage(ChatRole.User, "Hi") }, new ChatOptions(), CancellationToken.None); + + Assert.Equal("Hello", chatResponse.Text); + Assert.Equal("chat-model", chatResponse.ModelId); + Assert.NotNull(chatResponse.Usage); + } + + [Fact] + public async Task ChatAdapter_StreamsUpdates() + { + var payload = "data: {\"id\":\"chunk\",\"model\":\"chat-model\",\"choices\":[{\"delta\":{\"content\":\"He\"}}]}\n" + + "data: {\"id\":\"chunk\",\"model\":\"chat-model\",\"choices\":[{\"delta\":{\"content\":\"llo\"}}]}\n" + + "data: [DONE]\n"; + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(payload) + }; + + var adapter = new TogetherAIChatClient(new ChatCompletionClient(CreateMockHttpClient(response)), "chat-model"); + var updates = new List(); + await foreach (var update in adapter.GetStreamingResponseAsync(new[] { new ChatMessage(ChatRole.User, "Hi") }, new ChatOptions(), CancellationToken.None)) + { + updates.Add(update); + } + + Assert.Equal("Hello", string.Concat(updates.Select(u => u.Text))); + } + + [Fact] + public async Task EmbeddingAdapter_GeneratesEmbeddings() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"id":"emb","model":"embed-model","object":"list","data":[{"index":0,"object":"embedding","embedding":[0.1,0.2]}]}""") + }; + + var adapter = new TogetherAIEmbeddingGenerator(new EmbeddingClient(CreateMockHttpClient(response)), "embed-model"); + var result = await adapter.GenerateAsync(new[] { "test" }, new EmbeddingGenerationOptions(), CancellationToken.None); + + Assert.Single(result); + Assert.Equal(2, result.First().Vector.Length); + } + + [Fact] + public async Task SpeechToTextAdapter_ReturnsText() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("""{"text":"transcribed"}""") + }; + + var adapter = new TogetherAISpeechToTextClient(new AudioClient(CreateMockHttpClient(response)), "whisper"); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("audio")); + var result = await adapter.GetTextAsync(stream, new SpeechToTextOptions(), CancellationToken.None); + + Assert.Equal("transcribed", result.Text); + } + + [Fact] + public async Task ImageAdapter_ReturnsImageContent() + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"id":"img","model":"image-model","object":"list","data":[{"index":0,"url":"https://example.com/image.png"}]}""") + }; + + var adapter = new TogetherAIImageClient(new ImageClient(CreateMockHttpClient(response)), "image-model"); + var chatResponse = await adapter.GetResponseAsync(new[] { new ChatMessage(ChatRole.User, "make image") }, new ChatOptions(), CancellationToken.None); + + Assert.Single(chatResponse.Messages.First().Contents); + Assert.IsType(chatResponse.Messages.First().Contents[0]); + } +} diff --git a/Together.Tests/Together.Tests.csproj b/Together.Tests/Together.Tests.csproj index 0bc5d26..bcb7d10 100644 --- a/Together.Tests/Together.Tests.csproj +++ b/Together.Tests/Together.Tests.csproj @@ -4,6 +4,7 @@ enable false true + $(NoWarn);MEAI001 @@ -11,24 +12,20 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + diff --git a/Together.sln b/Together.sln deleted file mode 100644 index a535eec..0000000 --- a/Together.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Together", "Together\Together.csproj", "{093687C0-C903-4FD6-A4C4-670A8DA21001}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Together.Tests", "Together.Tests\Together.Tests.csproj", "{BB29DE40-8FA5-4716-923B-67A43FE90334}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Together.SemanticKernel", "Together.SemanticKernel\Together.SemanticKernel.csproj", "{B32D467B-E9D4-48D2-A61E-0CF745E189E2}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {093687C0-C903-4FD6-A4C4-670A8DA21001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {093687C0-C903-4FD6-A4C4-670A8DA21001}.Debug|Any CPU.Build.0 = Debug|Any CPU - {093687C0-C903-4FD6-A4C4-670A8DA21001}.Release|Any CPU.ActiveCfg = Release|Any CPU - {093687C0-C903-4FD6-A4C4-670A8DA21001}.Release|Any CPU.Build.0 = Release|Any CPU - {BB29DE40-8FA5-4716-923B-67A43FE90334}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BB29DE40-8FA5-4716-923B-67A43FE90334}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BB29DE40-8FA5-4716-923B-67A43FE90334}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BB29DE40-8FA5-4716-923B-67A43FE90334}.Release|Any CPU.Build.0 = Release|Any CPU - {B32D467B-E9D4-48D2-A61E-0CF745E189E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B32D467B-E9D4-48D2-A61E-0CF745E189E2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B32D467B-E9D4-48D2-A61E-0CF745E189E2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B32D467B-E9D4-48D2-A61E-0CF745E189E2}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Together.slnx b/Together.slnx new file mode 100644 index 0000000..369ec10 --- /dev/null +++ b/Together.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Together/Clients/AudioClient.cs b/Together/Clients/AudioClient.cs new file mode 100644 index 0000000..ba23fa0 --- /dev/null +++ b/Together/Clients/AudioClient.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Together.Models.Audio; + +namespace Together.Clients; + +public class AudioClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task CreateSpeechAsync(AudioSpeechRequest request, CancellationToken cancellationToken = default) + { + var response = await SendRequestAsync("/audio/speech", request, cancellationToken); + if (request.Stream) + { + return new AudioSpeechResult + { + Stream = ParseSpeechStreamAsync(response, cancellationToken) + }; + } + + var data = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return new AudioSpeechResult { Data = data }; + } + + private async IAsyncEnumerable ParseSpeechStreamAsync(HttpResponseMessage response, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + { + continue; + } + + if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var payload = line.Substring("data:".Length).Trim(); + if (string.Equals(payload, "[DONE]", StringComparison.Ordinal)) + { + yield break; + } + + if (string.IsNullOrWhiteSpace(payload)) + { + continue; + } + + var chunk = JsonSerializer.Deserialize(payload); + if (chunk?.Base64 is { Length: > 0 } b64 && TryDecodeBase64(b64, out var data)) + { + yield return data; + } + } + } + + public async Task CreateTranscriptionAsync(AudioFileRequest request, CancellationToken cancellationToken = default) + { + using var content = BuildMultipartContent(request); + using var response = await SendRequestAsync("/audio/transcriptions", HttpMethod.Post, content, cancellationToken); + var payload = await response.Content.ReadAsStringAsync(cancellationToken); + return BuildTranscriptionResult(request.ResponseFormat, payload); + } + + public async Task CreateTranslationAsync(AudioFileRequest request, CancellationToken cancellationToken = default) + { + using var content = BuildMultipartContent(request); + using var response = await SendRequestAsync("/audio/translations", HttpMethod.Post, content, cancellationToken); + var payload = await response.Content.ReadAsStringAsync(cancellationToken); + return BuildTranslationResult(request.ResponseFormat, payload); + } + + private static MultipartFormDataContent BuildMultipartContent(AudioFileRequest request) + { + var content = new MultipartFormDataContent(); + + var streamContent = new StreamContent(request.Content); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Add(streamContent, "file", request.FileName); + + content.Add(new StringContent(request.Model), "model"); + content.Add(new StringContent(request.ResponseFormat), "response_format"); + content.Add(new StringContent(request.Temperature.ToString(System.Globalization.CultureInfo.InvariantCulture)), "temperature"); + + if (!string.IsNullOrWhiteSpace(request.Language)) + { + content.Add(new StringContent(request.Language), "language"); + } + + if (!string.IsNullOrWhiteSpace(request.Prompt)) + { + content.Add(new StringContent(request.Prompt), "prompt"); + } + + if (request.TimestampGranularities is not null) + { + foreach (var granularity in request.TimestampGranularities) + { + content.Add(new StringContent(granularity), "timestamp_granularities"); + } + } + + if (request.AdditionalFields is not null) + { + foreach (var (key, value) in request.AdditionalFields) + { + content.Add(new StringContent(value), key); + } + } + + return content; + } + + private static AudioTranscriptionResult BuildTranscriptionResult(string responseFormat, string json) + { + var result = new AudioTranscriptionResult { RawJson = json }; + var format = responseFormat?.ToLowerInvariant(); + result.VerboseResponse = format == "verbose_json" + ? JsonSerializer.Deserialize(json) + : null; + result.Response = format == "verbose_json" + ? null + : JsonSerializer.Deserialize(json); + + return result; + } + + private static AudioTranslationResult BuildTranslationResult(string responseFormat, string json) + { + var result = new AudioTranslationResult { RawJson = json }; + var format = responseFormat?.ToLowerInvariant(); + result.VerboseResponse = format == "verbose_json" + ? JsonSerializer.Deserialize(json) + : null; + result.Response = format == "verbose_json" + ? null + : JsonSerializer.Deserialize(json); + + return result; + } + + private static bool TryDecodeBase64(string value, out byte[] data) + { + try + { + data = Convert.FromBase64String(value); + return true; + } + catch (FormatException) + { + data = Array.Empty(); + return false; + } + } +} diff --git a/Together/Clients/BatchClient.cs b/Together/Clients/BatchClient.cs new file mode 100644 index 0000000..e63a2ad --- /dev/null +++ b/Together/Clients/BatchClient.cs @@ -0,0 +1,35 @@ +using System.Net.Http; +using System.Net.Http.Json; +using Together.Models.Batch; + +namespace Together.Clients; + +public class BatchClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task CreateAsync(BatchCreateRequest request, CancellationToken cancellationToken = default) + { + var response = await SendRequestAsync("/batches", request, cancellationToken); + if (response.Job is null) + { + throw new InvalidOperationException("Batch job response did not include job details."); + } + + return response.Job; + } + + public async Task RetrieveAsync(string batchId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/batches/{batchId}", HttpMethod.Get, null, cancellationToken); + } + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var jobs = await SendRequestAsync>("/batches", HttpMethod.Get, null, cancellationToken); + return jobs; + } + + public async Task CancelAsync(string batchId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/batches/{batchId}/cancel", HttpMethod.Post, null, cancellationToken); + } +} diff --git a/Together/Clients/CodeInterpreterClient.cs b/Together/Clients/CodeInterpreterClient.cs new file mode 100644 index 0000000..b36f6b8 --- /dev/null +++ b/Together/Clients/CodeInterpreterClient.cs @@ -0,0 +1,12 @@ +using System.Net.Http; +using Together.Models.CodeInterpreter; + +namespace Together.Clients; + +public class CodeInterpreterClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task RunAsync(CodeInterpreterRequest request, CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/tci/execute", request, cancellationToken); + } +} diff --git a/Together/Clients/EndpointClient.cs b/Together/Clients/EndpointClient.cs new file mode 100644 index 0000000..c2e682f --- /dev/null +++ b/Together/Clients/EndpointClient.cs @@ -0,0 +1,57 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Together.Models.Endpoints; + +namespace Together.Clients; + +public class EndpointClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task> ListAsync(string? type = null, CancellationToken cancellationToken = default) + { + var url = "/endpoints"; + if (!string.IsNullOrWhiteSpace(type)) + { + url += $"?type={Uri.EscapeDataString(type)}"; + } + + var response = await SendRequestAsync(url, HttpMethod.Get, null, cancellationToken); + if (response.TryGetPropertyValue("data", out var dataNode) && dataNode is JsonArray array) + { + var results = new List(); + foreach (var item in array) + { + if (item is null) + { + continue; + } + + var model = item.Deserialize(); + if (model != null) + { + results.Add(model); + } + } + + return results; + } + + return Array.Empty(); + } + + public async Task CreateAsync(EndpointCreateRequest request, CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/endpoints", request, cancellationToken); + } + + public async Task RetrieveAsync(string endpointId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/endpoints/{endpointId}", HttpMethod.Get, null, cancellationToken); + } + + public async Task DeleteAsync(string endpointId, CancellationToken cancellationToken = default) + { + await SendRequestAsync($"/endpoints/{endpointId}", HttpMethod.Delete, null, cancellationToken); + } +} diff --git a/Together/Clients/EvaluationClient.cs b/Together/Clients/EvaluationClient.cs new file mode 100644 index 0000000..754636a --- /dev/null +++ b/Together/Clients/EvaluationClient.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Together.Models.Evaluations; + +namespace Together.Clients; + +public class EvaluationClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task CreateAsync(EvaluationCreateRequest request, CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/evaluation", request, cancellationToken); + } + + public async Task> ListAsync(EvaluationListOptions? options = null, CancellationToken cancellationToken = default) + { + var url = "/evaluations"; + if (options is not null) + { + var query = new List(); + if (!string.IsNullOrWhiteSpace(options.Status)) + { + query.Add($"status={Uri.EscapeDataString(options.Status)}"); + } + + if (options.Limit.HasValue) + { + query.Add($"limit={options.Limit.Value}"); + } + + if (query.Count > 0) + { + url += "?" + string.Join("&", query); + } + } + + return await SendRequestAsync>(url, HttpMethod.Get, null, cancellationToken); + } + + public async Task RetrieveAsync(string workflowId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/evaluation/{workflowId}", HttpMethod.Get, null, cancellationToken); + } + + public async Task GetStatusAsync(string workflowId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/evaluation/{workflowId}/status", HttpMethod.Get, null, cancellationToken); + } + + public async Task GetAllowedModelsAsync(CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/evaluations/model-list", HttpMethod.Get, null, cancellationToken); + } +} diff --git a/Together/Clients/HardwareClient.cs b/Together/Clients/HardwareClient.cs new file mode 100644 index 0000000..149d39e --- /dev/null +++ b/Together/Clients/HardwareClient.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; +using Together.Models.Endpoints; + +namespace Together.Clients; + +public class HardwareClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) + { + var url = "/hardware"; + if (!string.IsNullOrWhiteSpace(model)) + { + url += $"?model={Uri.EscapeDataString(model)}"; + } + + return await SendRequestAsync(url, HttpMethod.Get, null, cancellationToken); + } +} diff --git a/Together/Clients/JobClient.cs b/Together/Clients/JobClient.cs new file mode 100644 index 0000000..1434245 --- /dev/null +++ b/Together/Clients/JobClient.cs @@ -0,0 +1,17 @@ +using System.Net.Http; +using Together.Models.Jobs; + +namespace Together.Clients; + +public class JobClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task RetrieveAsync(string jobId, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/jobs/{jobId}", HttpMethod.Get, null, cancellationToken); + } + + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/jobs", HttpMethod.Get, null, cancellationToken); + } +} diff --git a/Together/Clients/VideoClient.cs b/Together/Clients/VideoClient.cs new file mode 100644 index 0000000..23227be --- /dev/null +++ b/Together/Clients/VideoClient.cs @@ -0,0 +1,17 @@ +using System.Net.Http; +using Together.Models.Videos; + +namespace Together.Clients; + +public class VideoClient(HttpClient httpClient) : BaseClient(httpClient) +{ + public async Task CreateAsync(CreateVideoRequest request, CancellationToken cancellationToken = default) + { + return await SendRequestAsync("/../v2/videos", request, cancellationToken); + } + + public async Task RetrieveAsync(string id, CancellationToken cancellationToken = default) + { + return await SendRequestAsync($"/../v2/videos/{id}", HttpMethod.Get, null, cancellationToken); + } +} diff --git a/Together/Extensions/MicrosoftAI/ChatOptionConverters.cs b/Together/Extensions/MicrosoftAI/ChatOptionConverters.cs new file mode 100644 index 0000000..847156f --- /dev/null +++ b/Together/Extensions/MicrosoftAI/ChatOptionConverters.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Together.Models.ChatCompletions; + +namespace Together.Extensions.MicrosoftAI; + +internal static class ChatOptionConverters +{ + public static ChatCompletionRequest ToChatCompletionRequest(this IEnumerable messages, ChatOptions? options, string defaultModel) + { + var model = options?.ModelId ?? defaultModel; + if (string.IsNullOrWhiteSpace(model)) + { + throw new InvalidOperationException("A model identifier must be provided through ChatOptions.ModelId or constructor."); + } + + var request = new ChatCompletionRequest + { + Model = model, + Messages = messages.Select(ConvertMessage).ToList(), + Temperature = options?.Temperature, + TopP = options?.TopP, + TopK = options?.TopK, + PresencePenalty = options?.PresencePenalty, + FrequencyPenalty = options?.FrequencyPenalty, + MaxTokens = options?.MaxOutputTokens, + Seed = options?.Seed is long seedValue ? (ulong?)seedValue : null, + Stop = options?.StopSequences?.ToList(), + Tools = options?.Tools?.Select(ConvertTool).ToList() + }; + + if (options?.ToolMode is RequiredChatToolMode requiredMode) + { + request.ToolChoice = string.IsNullOrEmpty(requiredMode.RequiredFunctionName) + ? "required" + : new ToolChoice + { + Type = "function", + Function = new FunctionToolChoice { Name = requiredMode.RequiredFunctionName } + }; + } + else if (options?.ToolMode is AutoChatToolMode) + { + request.ToolChoice = "auto"; + } + else if (options?.ToolMode is NoneChatToolMode) + { + request.ToolChoice = "none"; + } + + if (options?.AdditionalProperties is { Count: > 0 }) + { + request.AdditionalParameters = new Dictionary(); + foreach (var property in options.AdditionalProperties) + { + request.AdditionalParameters[property.Key] = property.Value; + } + } + + return request; + } + + private static ChatCompletionMessage ConvertMessage(ChatMessage message) + { + var text = message.Text; + if (string.IsNullOrWhiteSpace(text) && message.Contents is { Count: > 0 }) + { + text = string.Join("\n", message.Contents.OfType().Select(c => c.Text)); + } + + return new ChatCompletionMessage + { + Role = message.Role, + Content = text, + ToolCalls = message.Contents?.OfType() + .Select(call => new ToolCall + { + Id = string.IsNullOrEmpty(call.CallId) ? Guid.NewGuid().ToString() : call.CallId, + Type = "function", + Function = new FunctionCall + { + Name = call.Name, + Arguments = call.Arguments is { Count: > 0 } args + ? JsonSerializer.Serialize(args) + : "{}" + } + }) + .ToList() + }; + } + + private static Tool ConvertTool(AITool tool) + { + return new Tool + { + Type = "function", + Function = new FunctionTool + { + Name = tool.Name, + Description = tool.Description, + Parameters = tool.AdditionalProperties is { Count: > 0 } + ? tool.AdditionalProperties.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value) + : new Dictionary() + } + }; + } +} diff --git a/Together/Extensions/MicrosoftAI/TogetherAIChatClient.cs b/Together/Extensions/MicrosoftAI/TogetherAIChatClient.cs new file mode 100644 index 0000000..5acd4d0 --- /dev/null +++ b/Together/Extensions/MicrosoftAI/TogetherAIChatClient.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Together.Clients; +using Together.Models.ChatCompletions; +using Together.Models.Common; + +namespace Together.Extensions.MicrosoftAI; + +public class TogetherAIChatClient : IChatClient +{ + private readonly ChatCompletionClient _chatClient; + private readonly string _defaultModel; + + public TogetherAIChatClient(ChatCompletionClient chatClient, string defaultModel) + { + _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + _defaultModel = defaultModel; + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken = default) + { + var request = messages.ToChatCompletionRequest(options, _defaultModel); + var response = await _chatClient.CreateAsync(request, cancellationToken); + + var choice = response.Choices.FirstOrDefault(); + var message = choice?.Message; + var chatMessage = new ChatMessage(message?.Role ?? ChatRole.Assistant, message?.Content ?? string.Empty) + { + AuthorName = message?.Role.ToString() + }; + + var chatResponse = new ChatResponse(new List { chatMessage }) + { + ResponseId = response.Id, + ModelId = response.Model, + CreatedAt = response.Created.HasValue ? DateTimeOffset.FromUnixTimeSeconds(response.Created.Value) : null, + FinishReason = choice?.FinishReason is FinishReason reason ? MapFinishReason(reason) : null, + Usage = response.Usage is null ? null : new UsageDetails + { + InputTokenCount = response.Usage.PromptTokens, + OutputTokenCount = response.Usage.CompletionTokens, + TotalTokenCount = response.Usage.TotalTokens + } + }; + + return chatResponse; + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var request = messages.ToChatCompletionRequest(options, _defaultModel); + request.Stream = true; + + await foreach (var chunk in _chatClient.CreateStreamAsync(request, cancellationToken)) + { + var delta = chunk.Choices.FirstOrDefault()?.Delta; + if (delta is null) + { + continue; + } + + if (string.IsNullOrEmpty(delta.Content)) + { + continue; + } + + var update = new ChatResponseUpdate(ChatRole.Assistant, delta.Content); + + update.ResponseId = chunk.Id; + update.ModelId = chunk.Model; + update.CreatedAt = chunk.Created.HasValue ? DateTimeOffset.FromUnixTimeSeconds(chunk.Created.Value) : null; + + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey) + { + if (serviceType == typeof(ChatCompletionClient)) + { + return _chatClient; + } + + return null; + } + + public void Dispose() + { + } + + private static ChatFinishReason? MapFinishReason(FinishReason reason) => reason.Value.ToLowerInvariant() switch + { + "length" => ChatFinishReason.Length, + "stop" => ChatFinishReason.Stop, + "tool_calls" => ChatFinishReason.ToolCalls, + "content_filter" => ChatFinishReason.ContentFilter, + _ => null + }; +} diff --git a/Together/Extensions/MicrosoftAI/TogetherAIClientExtensions.cs b/Together/Extensions/MicrosoftAI/TogetherAIClientExtensions.cs new file mode 100644 index 0000000..e987358 --- /dev/null +++ b/Together/Extensions/MicrosoftAI/TogetherAIClientExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.AI; +using Together.Clients; + +namespace Together.Extensions.MicrosoftAI; + +public static class TogetherAIClientExtensions +{ + public static IChatClient AsMicrosoftAIChatClient(this TogetherClient client, string defaultModel) + { + return new TogetherAIChatClient(client.ChatCompletions, defaultModel); + } + + public static IChatClient AsMicrosoftAIImageClient(this TogetherClient client, string defaultModel) + { + return new TogetherAIImageClient(client.Images, defaultModel); + } + + public static IEmbeddingGenerator> AsMicrosoftAIEmbeddingGenerator(this TogetherClient client, string defaultModel) + { + return new TogetherAIEmbeddingGenerator(client.Embeddings, defaultModel); + } + + public static ISpeechToTextClient AsMicrosoftAISpeechToTextClient(this TogetherClient client, string defaultModel) + { + return new TogetherAISpeechToTextClient(client.Audio, defaultModel); + } +} diff --git a/Together/Extensions/MicrosoftAI/TogetherAIEmbeddingGenerator.cs b/Together/Extensions/MicrosoftAI/TogetherAIEmbeddingGenerator.cs new file mode 100644 index 0000000..546e990 --- /dev/null +++ b/Together/Extensions/MicrosoftAI/TogetherAIEmbeddingGenerator.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Together.Clients; +using Together.Models.Embeddings; + +namespace Together.Extensions.MicrosoftAI; + +public class TogetherAIEmbeddingGenerator : IEmbeddingGenerator> +{ + private readonly EmbeddingClient _embeddingClient; + private readonly string _defaultModel; + + public TogetherAIEmbeddingGenerator(EmbeddingClient embeddingClient, string defaultModel) + { + _embeddingClient = embeddingClient ?? throw new ArgumentNullException(nameof(embeddingClient)); + _defaultModel = defaultModel; + } + + public async Task>> GenerateAsync(IEnumerable inputs, EmbeddingGenerationOptions? options, CancellationToken cancellationToken = default) + { + var inputList = inputs?.ToList() ?? throw new ArgumentNullException(nameof(inputs)); + if (inputList.Count == 0) + { + throw new ArgumentException("At least one input is required for embedding generation.", nameof(inputs)); + } + + var model = options?.ModelId ?? _defaultModel; + if (string.IsNullOrWhiteSpace(model)) + { + throw new InvalidOperationException("A model identifier must be provided for embedding generation."); + } + + var request = new EmbeddingRequest + { + Model = model, + Input = inputList.Count == 1 ? inputList[0] : inputList + }; + + var response = await _embeddingClient.CreateAsync(request, cancellationToken); + var embeddings = response.Data + .OrderBy(d => d.Index) + .Select(d => new Embedding(d.Embedding.ToArray())) + .ToList(); + + return new GeneratedEmbeddings>(embeddings); + } + + public object? GetService(Type serviceType, object? serviceKey) + { + if (serviceType == typeof(EmbeddingClient)) + { + return _embeddingClient; + } + + return null; + } + + public void Dispose() + { + } +} diff --git a/Together/Extensions/MicrosoftAI/TogetherAIImageClient.cs b/Together/Extensions/MicrosoftAI/TogetherAIImageClient.cs new file mode 100644 index 0000000..bdc389c --- /dev/null +++ b/Together/Extensions/MicrosoftAI/TogetherAIImageClient.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Together.Clients; +using Together.Models.Images; + +namespace Together.Extensions.MicrosoftAI; + +public class TogetherAIImageClient : IChatClient +{ + private readonly ImageClient _imageClient; + private readonly string _defaultModel; + + public TogetherAIImageClient(ImageClient imageClient, string defaultModel) + { + _imageClient = imageClient ?? throw new ArgumentNullException(nameof(imageClient)); + _defaultModel = defaultModel; + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken = default) + { + var promptMessage = messages.LastOrDefault(m => m.Role == ChatRole.User) ?? throw new InvalidOperationException("Image generation requires a user prompt."); + var prompt = promptMessage.Text ?? string.Empty; + + var request = BuildImageRequest(prompt, options); + var response = await _imageClient.GenerateAsync(request, cancellationToken); + + var contents = response.Data + .OrderBy(d => d.Index) + .SelectMany(CreateContent) + .ToList(); + + var assistantMessage = new ChatMessage(ChatRole.Assistant, contents); + + return new ChatResponse(new List { assistantMessage }) + { + ResponseId = response.Id, + ModelId = response.Model + }; + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var response = await GetResponseAsync(messages, options, cancellationToken); + var contents = response.Messages.First().Contents; + var update = new ChatResponseUpdate(ChatRole.Assistant, contents) + { + ModelId = response.ModelId, + ResponseId = response.ResponseId, + FinishReason = ChatFinishReason.Stop + }; + + yield return update; + } + + public object? GetService(Type serviceType, object? serviceKey) + { + if (serviceType == typeof(ImageClient)) + { + return _imageClient; + } + + return null; + } + + public void Dispose() + { + } + + private ImageRequest BuildImageRequest(string prompt, ChatOptions? options) + { + var model = options?.ModelId ?? _defaultModel; + if (string.IsNullOrWhiteSpace(model)) + { + throw new InvalidOperationException("A model identifier must be provided for image generation."); + } + + var request = new ImageRequest + { + Model = model, + Prompt = prompt, + Height = options?.AdditionalProperties?.TryGetValue("height", out var height) == true && int.TryParse(height?.ToString(), out var h) ? h : (int?)null, + Width = options?.AdditionalProperties?.TryGetValue("width", out var width) == true && int.TryParse(width?.ToString(), out var w) ? w : (int?)null, + Steps = options?.AdditionalProperties?.TryGetValue("steps", out var steps) == true && int.TryParse(steps?.ToString(), out var s) ? s : (int?)null, + ResponseFormat = options?.AdditionalProperties?.TryGetValue("response_format", out var format) == true ? format?.ToString() ?? "url" : "url", + NegativePrompt = options?.AdditionalProperties?.TryGetValue("negative_prompt", out var negative) == true ? negative?.ToString() : null + }; + + return request; + } + + private static IEnumerable CreateContent(ImageChoicesData data) + { + if (!string.IsNullOrEmpty(data.Url) && Uri.TryCreate(data.Url, UriKind.Absolute, out var uri)) + { + yield return new UriContent(uri, "image/png"); + } + + if (!string.IsNullOrEmpty(data.B64Json)) + { + var bytes = Convert.FromBase64String(data.B64Json); + yield return new DataContent(bytes, "image/png"); + } + } +} diff --git a/Together/Extensions/MicrosoftAI/TogetherAISpeechToTextClient.cs b/Together/Extensions/MicrosoftAI/TogetherAISpeechToTextClient.cs new file mode 100644 index 0000000..147195e --- /dev/null +++ b/Together/Extensions/MicrosoftAI/TogetherAISpeechToTextClient.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Together.Clients; +using Together.Models.Audio; + +namespace Together.Extensions.MicrosoftAI; + +public class TogetherAISpeechToTextClient : ISpeechToTextClient +{ + private readonly AudioClient _audioClient; + private readonly string _defaultModel; + + public TogetherAISpeechToTextClient(AudioClient audioClient, string defaultModel) + { + _audioClient = audioClient ?? throw new ArgumentNullException(nameof(audioClient)); + _defaultModel = defaultModel; + } + + public async Task GetTextAsync(Stream audio, SpeechToTextOptions? options, CancellationToken cancellationToken = default) + { + if (audio is null) + { + throw new ArgumentNullException(nameof(audio)); + } + + var model = options?.ModelId ?? _defaultModel; + if (string.IsNullOrWhiteSpace(model)) + { + throw new InvalidOperationException("A model identifier must be provided for speech to text."); + } + + var request = new AudioFileRequest + { + Content = audio, + FileName = options?.AdditionalProperties?.TryGetValue("file_name", out var name) == true ? name?.ToString() ?? "audio.wav" : "audio.wav", + Model = model, + Language = options?.SpeechLanguage, + ResponseFormat = "json" + }; + + var transcription = await _audioClient.CreateTranscriptionAsync(request, cancellationToken); + var text = transcription.Response?.Text ?? transcription.VerboseResponse?.Text ?? string.Empty; + + return new SpeechToTextResponse(text) + { + ModelId = model + }; + } + + public async IAsyncEnumerable GetStreamingTextAsync(Stream audio, SpeechToTextOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var response = await GetTextAsync(audio, options, cancellationToken); + yield return new SpeechToTextResponseUpdate(response.Text) + { + Kind = SpeechToTextResponseUpdateKind.TextUpdated, + ModelId = response.ModelId + }; + } + + public object? GetService(Type serviceType, object? serviceKey) + { + if (serviceType == typeof(AudioClient)) + { + return _audioClient; + } + + return null; + } + + public void Dispose() + { + } +} diff --git a/Together/Models/Audio/AudioModels.cs b/Together/Models/Audio/AudioModels.cs new file mode 100644 index 0000000..85f86ea --- /dev/null +++ b/Together/Models/Audio/AudioModels.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Together.Models.Audio; + +public class AudioSpeechRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("input")] + public string Input { get; set; } = string.Empty; + + [JsonPropertyName("voice")] + public string? Voice { get; set; } + + [JsonPropertyName("response_format")] + public string ResponseFormat { get; set; } = "wav"; + + [JsonPropertyName("language")] + public string Language { get; set; } = "en"; + + [JsonPropertyName("response_encoding")] + public string ResponseEncoding { get; set; } = "pcm_f32le"; + + [JsonPropertyName("sample_rate")] + public int SampleRate { get; set; } = 44100; + + [JsonPropertyName("stream")] + public bool Stream { get; set; } +} + +public class AudioSpeechStreamChunk +{ + [JsonPropertyName("b64")] + public string Base64 { get; set; } = string.Empty; +} + +public class AudioSpeechResult +{ + public byte[]? Data { get; init; } + + public IAsyncEnumerable? Stream { get; init; } +} + +public class AudioFileRequest +{ + public Stream Content { get; set; } = Stream.Null; + public string FileName { get; set; } = "audio.wav"; + public string Model { get; set; } = "openai/whisper-large-v3"; + public string ResponseFormat { get; set; } = "json"; + public string? Language { get; set; } + public string? Prompt { get; set; } + public double Temperature { get; set; } = 0.0; + public IEnumerable? TimestampGranularities { get; set; } + public Dictionary? AdditionalFields { get; set; } +} + +public class AudioTranscriptionResponse +{ + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; +} + +public class AudioTranscriptionResult +{ + public AudioTranscriptionResponse? Response { get; set; } + public AudioTranscriptionVerboseResponse? VerboseResponse { get; set; } + public string? RawJson { get; set; } +} + +public class AudioTranscriptionVerboseResponse +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("duration")] + public double? Duration { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("segments")] + public JsonElement? Segments { get; set; } + + [JsonPropertyName("words")] + public JsonElement? Words { get; set; } + + [JsonPropertyName("speaker_segments")] + public JsonElement? SpeakerSegments { get; set; } +} + +public class AudioTranslationResponse +{ + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; +} + +public class AudioTranslationResult +{ + public AudioTranslationResponse? Response { get; set; } + public AudioTranslationVerboseResponse? VerboseResponse { get; set; } + public string? RawJson { get; set; } +} + +public class AudioTranslationVerboseResponse +{ + [JsonPropertyName("task")] + public string? Task { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("duration")] + public double? Duration { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("segments")] + public JsonElement? Segments { get; set; } + + [JsonPropertyName("words")] + public JsonElement? Words { get; set; } +} diff --git a/Together/Models/Batch/BatchJob.cs b/Together/Models/Batch/BatchJob.cs new file mode 100644 index 0000000..ddec505 --- /dev/null +++ b/Together/Models/Batch/BatchJob.cs @@ -0,0 +1,87 @@ +using System; +using System.Text.Json.Serialization; + +namespace Together.Models.Batch; + +public enum BatchJobStatus +{ + Validating, + InProgress, + Completed, + Failed, + Expired, + Cancelled, + Canceling +} + +public enum BatchEndpoint +{ + [JsonPropertyName("/v1/completions")] + Completions, + + [JsonPropertyName("/v1/chat/completions")] + ChatCompletions +} + +public class BatchJob +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("user_id")] + public string UserId { get; set; } = string.Empty; + + [JsonPropertyName("input_file_id")] + public string InputFileId { get; set; } = string.Empty; + + [JsonPropertyName("file_size_bytes")] + public long FileSizeBytes { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("job_deadline")] + public DateTime JobDeadline { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("endpoint")] + public string Endpoint { get; set; } = string.Empty; + + [JsonPropertyName("progress")] + public double Progress { get; set; } + + [JsonPropertyName("model_id")] + public string? ModelId { get; set; } + + [JsonPropertyName("output_file_id")] + public string? OutputFileId { get; set; } + + [JsonPropertyName("error_file_id")] + public string? ErrorFileId { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("completed_at")] + public DateTime? CompletedAt { get; set; } +} + +public class BatchCreateRequest +{ + [JsonPropertyName("input_file_id")] + public string InputFileId { get; set; } = string.Empty; + + [JsonPropertyName("endpoint")] + public string Endpoint { get; set; } = string.Empty; + + [JsonPropertyName("completion_window")] + public string CompletionWindow { get; set; } = "24h"; +} + +public class BatchCreateResponse +{ + [JsonPropertyName("job")] + public BatchJob? Job { get; set; } +} diff --git a/Together/Models/ChatCompletions/ChatCompletionRequest.cs b/Together/Models/ChatCompletions/ChatCompletionRequest.cs index 0f41bcd..77131a4 100644 --- a/Together/Models/ChatCompletions/ChatCompletionRequest.cs +++ b/Together/Models/ChatCompletions/ChatCompletionRequest.cs @@ -67,6 +67,9 @@ public class ChatCompletionRequest [JsonPropertyName("tool_choice")] public object ToolChoice { get; set; } + [JsonExtensionData] + public Dictionary? AdditionalParameters { get; set; } + public void VerifyParameters() { if (RepetitionPenalty.HasValue && (PresencePenalty.HasValue || FrequencyPenalty.HasValue)) diff --git a/Together/Models/ChatCompletions/FunctionTool.cs b/Together/Models/ChatCompletions/FunctionTool.cs index f8f40c5..9649e32 100644 --- a/Together/Models/ChatCompletions/FunctionTool.cs +++ b/Together/Models/ChatCompletions/FunctionTool.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Together.Models.ChatCompletions; @@ -11,5 +12,5 @@ public class FunctionTool public string Name { get; set; } [JsonPropertyName("parameters")] - public Dictionary Parameters { get; set; } + public Dictionary Parameters { get; set; } } \ No newline at end of file diff --git a/Together/Models/CodeInterpreter/CodeInterpreterModels.cs b/Together/Models/CodeInterpreter/CodeInterpreterModels.cs new file mode 100644 index 0000000..01e0902 --- /dev/null +++ b/Together/Models/CodeInterpreter/CodeInterpreterModels.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Together.Models.CodeInterpreter; + +public class FileInput +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("encoding")] + public string Encoding { get; set; } = "string"; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +public class InterpreterOutput +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("data")] + public object? Data { get; set; } +} + +public class ExecuteResponseData +{ + [JsonPropertyName("outputs")] + public List Outputs { get; set; } = new(); + + [JsonPropertyName("errors")] + public string? Errors { get; set; } + + [JsonPropertyName("session_id")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = "completed"; +} + +public class ExecuteResponse +{ + [JsonPropertyName("data")] + public ExecuteResponseData Data { get; set; } = new(); +} + +public class CodeInterpreterRequest +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("language")] + public string Language { get; set; } = "python"; + + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + + [JsonPropertyName("files")] + public List? Files { get; set; } +} diff --git a/Together/Models/Endpoints/EndpointsModels.cs b/Together/Models/Endpoints/EndpointsModels.cs new file mode 100644 index 0000000..37b62cb --- /dev/null +++ b/Together/Models/Endpoints/EndpointsModels.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Together.Models.Endpoints; + +public class Autoscaling +{ + [JsonPropertyName("min_replicas")] + public int MinReplicas { get; set; } + + [JsonPropertyName("max_replicas")] + public int MaxReplicas { get; set; } +} + +public class EndpointPricing +{ + [JsonPropertyName("cents_per_minute")] + public double CentsPerMinute { get; set; } +} + +public class HardwareSpec +{ + [JsonPropertyName("gpu_type")] + public string GpuType { get; set; } = string.Empty; + + [JsonPropertyName("gpu_link")] + public string GpuLink { get; set; } = string.Empty; + + [JsonPropertyName("gpu_memory")] + public double GpuMemory { get; set; } + + [JsonPropertyName("gpu_count")] + public int GpuCount { get; set; } +} + +public class HardwareAvailability +{ + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} + +public class HardwareWithStatus +{ + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("pricing")] + public EndpointPricing Pricing { get; set; } = new(); + + [JsonPropertyName("specs")] + public HardwareSpec Specs { get; set; } = new(); + + [JsonPropertyName("availability")] + public HardwareAvailability? Availability { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } +} + +public class HardwareListResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = new(); + + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; +} + +public abstract class BaseEndpoint +{ + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("owner")] + public string Owner { get; set; } = string.Empty; + + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} + +public class ListEndpoint : BaseEndpoint +{ +} + +public class DedicatedEndpoint : BaseEndpoint +{ + [JsonPropertyName("display_name")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("hardware")] + public string Hardware { get; set; } = string.Empty; + + [JsonPropertyName("autoscaling")] + public Autoscaling Autoscaling { get; set; } = new(); +} + +public class EndpointCreateRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("hardware")] + public string Hardware { get; set; } = string.Empty; + + [JsonPropertyName("autoscaling")] + public Autoscaling Autoscaling { get; set; } = new(); + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("disable_prompt_cache")] + public bool DisablePromptCache { get; set; } = true; + + [JsonPropertyName("disable_speculative_decoding")] + public bool DisableSpeculativeDecoding { get; set; } = true; + + [JsonPropertyName("state")] + public string State { get; set; } = "STARTED"; + + [JsonPropertyName("inactive_timeout")] + public int? InactiveTimeout { get; set; } +} diff --git a/Together/Models/Evaluations/EvaluationModels.cs b/Together/Models/Evaluations/EvaluationModels.cs new file mode 100644 index 0000000..411fb76 --- /dev/null +++ b/Together/Models/Evaluations/EvaluationModels.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Together.Models.Evaluations; + +public class EvaluationJob +{ + [JsonPropertyName("workflow_id")] + public string WorkflowId { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + public DateTime? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime? UpdatedAt { get; set; } +} + +public class EvaluationListResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = new(); +} + +public class EvaluationListOptions +{ + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("limit")] + public int? Limit { get; set; } +} + +public class EvaluationCreateResponse +{ + [JsonPropertyName("workflow_id")] + public string WorkflowId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} + +public class EvaluationStatusResponse +{ + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("results")] + public JsonElement? Results { get; set; } +} + +public class EvaluationRetrieveResponse +{ + [JsonPropertyName("workflow_id")] + public string WorkflowId { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("created_at")] + public DateTime? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime? UpdatedAt { get; set; } + + [JsonPropertyName("results")] + public JsonElement? Results { get; set; } + + [JsonPropertyName("parameters")] + public Dictionary? Parameters { get; set; } +} + +public class EvaluationAllowedModelsResponse +{ + [JsonPropertyName("models")] + public List Models { get; set; } = new(); +} + +public class JudgeModelConfig +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("model_source")] + public string ModelSource { get; set; } = string.Empty; + + [JsonPropertyName("system_template")] + public string SystemTemplate { get; set; } = string.Empty; + + [JsonPropertyName("external_api_token")] + public string? ExternalApiToken { get; set; } + + [JsonPropertyName("external_base_url")] + public string? ExternalBaseUrl { get; set; } +} + +public class ModelRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("model_source")] + public string ModelSource { get; set; } = string.Empty; + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; set; } + + [JsonPropertyName("temperature")] + public double Temperature { get; set; } + + [JsonPropertyName("system_template")] + public string SystemTemplate { get; set; } = string.Empty; + + [JsonPropertyName("input_template")] + public string InputTemplate { get; set; } = string.Empty; + + [JsonPropertyName("external_api_token")] + public string? ExternalApiToken { get; set; } + + [JsonPropertyName("external_base_url")] + public string? ExternalBaseUrl { get; set; } +} + +public class EvaluationCreateRequest +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("judge")] + public JudgeModelConfig Judge { get; set; } = new(); + + [JsonPropertyName("input_data_file_path")] + public string InputDataFilePath { get; set; } = string.Empty; + + [JsonPropertyName("labels")] + public List? Labels { get; set; } + + [JsonPropertyName("pass_labels")] + public List? PassLabels { get; set; } + + [JsonPropertyName("min_score")] + public double? MinScore { get; set; } + + [JsonPropertyName("max_score")] + public double? MaxScore { get; set; } + + [JsonPropertyName("pass_threshold")] + public double? PassThreshold { get; set; } + + [JsonPropertyName("model_to_evaluate")] + public object? ModelToEvaluate { get; set; } + + [JsonPropertyName("model_a")] + public object? ModelA { get; set; } + + [JsonPropertyName("model_b")] + public object? ModelB { get; set; } +} diff --git a/Together/Models/Jobs/JobModels.cs b/Together/Models/Jobs/JobModels.cs new file mode 100644 index 0000000..bda4f00 --- /dev/null +++ b/Together/Models/Jobs/JobModels.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Together.Models.Jobs; + +public class JobStatusUpdate +{ + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public string Timestamp { get; set; } = string.Empty; +} + +public class JobArgs +{ + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("modelName")] + public string? ModelName { get; set; } + + [JsonPropertyName("modelSource")] + public string? ModelSource { get; set; } +} + +public class JobRetrieveResponse +{ + [JsonPropertyName("args")] + public JobArgs Args { get; set; } = new(); + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = string.Empty; + + [JsonPropertyName("job_id")] + public string JobId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("status_updates")] + public List StatusUpdates { get; set; } = new(); + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("updated_at")] + public string UpdatedAt { get; set; } = string.Empty; +} + +public class JobListItem +{ + [JsonPropertyName("args")] + public JobArgs Args { get; set; } = new(); + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = string.Empty; + + [JsonPropertyName("job_id")] + public string JobId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("status_updates")] + public List StatusUpdates { get; set; } = new(); + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("updated_at")] + public string UpdatedAt { get; set; } = string.Empty; +} + +public class JobListResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = new(); +} diff --git a/Together/Models/Videos/VideoModels.cs b/Together/Models/Videos/VideoModels.cs new file mode 100644 index 0000000..f885e49 --- /dev/null +++ b/Together/Models/Videos/VideoModels.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Together.Models.Videos; + +public class CreateVideoRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } + + [JsonPropertyName("height")] + public int? Height { get; set; } + + [JsonPropertyName("width")] + public int? Width { get; set; } + + [JsonPropertyName("seconds")] + public string? Seconds { get; set; } + + [JsonPropertyName("fps")] + public int? FramesPerSecond { get; set; } + + [JsonPropertyName("steps")] + public int? Steps { get; set; } + + [JsonPropertyName("seed")] + public int? Seed { get; set; } + + [JsonPropertyName("guidance_scale")] + public float? GuidanceScale { get; set; } + + [JsonPropertyName("output_format")] + public string? OutputFormat { get; set; } + + [JsonPropertyName("output_quality")] + public int? OutputQuality { get; set; } + + [JsonPropertyName("negative_prompt")] + public string? NegativePrompt { get; set; } + + [JsonPropertyName("frame_images")] + public List>? FrameImages { get; set; } + + [JsonPropertyName("reference_images")] + public List? ReferenceImages { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; set; } +} + +public class CreateVideoResponse +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; +} + +public class VideoOutputs +{ + [JsonPropertyName("cost")] + public float Cost { get; set; } + + [JsonPropertyName("video_url")] + public string VideoUrl { get; set; } = string.Empty; +} + +public class VideoError +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +public class VideoJob +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + [JsonPropertyName("object")] + public string Object { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("seconds")] + public string Seconds { get; set; } = string.Empty; + + [JsonPropertyName("size")] + public string Size { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + public long CreatedAt { get; set; } + + [JsonPropertyName("error")] + public VideoError? Error { get; set; } + + [JsonPropertyName("outputs")] + public VideoOutputs? Outputs { get; set; } + + [JsonPropertyName("completed_at")] + public long? CompletedAt { get; set; } +} diff --git a/Together/Together.csproj b/Together/Together.csproj index 4b76b60..3fa2b18 100644 --- a/Together/Together.csproj +++ b/Together/Together.csproj @@ -2,10 +2,11 @@ enable + $(NoWarn);MEAI001 - + diff --git a/Together/TogetherClient.cs b/Together/TogetherClient.cs index db1672c..d814a91 100644 --- a/Together/TogetherClient.cs +++ b/Together/TogetherClient.cs @@ -35,9 +35,17 @@ public TogetherClient(string apiKey, HttpClient httpClient, string? baseUrl = nu Embeddings = new EmbeddingClient(_httpClient); Files = new FileClient(_httpClient); FineTune = new FineTuneClient(_httpClient); + Batches = new BatchClient(_httpClient); + Endpoints = new EndpointClient(_httpClient); + Hardware = new HardwareClient(_httpClient); + Evaluation = new EvaluationClient(_httpClient); + CodeInterpreter = new CodeInterpreterClient(_httpClient); + Audio = new AudioClient(_httpClient); Images = new ImageClient(_httpClient); Models = new ModelClient(_httpClient); Rerank = new RerankClient(_httpClient); + Jobs = new JobClient(_httpClient); + Videos = new VideoClient(_httpClient); } public CompletionClient Completions { get; } @@ -45,7 +53,15 @@ public TogetherClient(string apiKey, HttpClient httpClient, string? baseUrl = nu public EmbeddingClient Embeddings { get; } public FileClient Files { get; } public FineTuneClient FineTune { get; } + public BatchClient Batches { get; } + public EndpointClient Endpoints { get; } + public HardwareClient Hardware { get; } + public EvaluationClient Evaluation { get; } + public CodeInterpreterClient CodeInterpreter { get; } + public AudioClient Audio { get; } public ImageClient Images { get; } public ModelClient Models { get; } public RerankClient Rerank { get; } + public JobClient Jobs { get; } + public VideoClient Videos { get; } } \ No newline at end of file diff --git a/docs/api-coverage.md b/docs/api-coverage.md new file mode 100644 index 0000000..077cb76 --- /dev/null +++ b/docs/api-coverage.md @@ -0,0 +1,30 @@ +# Together API Coverage + +The table below tracks parity between the Together .NET SDK and the reference SDKs in [together-python](https://github.com/togethercomputer/together-python) and [together-typescript](https://github.com/togethercomputer/together-typescript). + +| Capability | REST endpoint(s) | .NET Support | Notes | +| --- | --- | --- | --- | +| Text completions | `/completions` | `TogetherClient.Completions` | Streaming and non-streaming requests | +| Chat completions & tool calling | `/chat/completions` | `TogetherClient.ChatCompletions` | Function/tool calling supported | +| Embeddings | `/embeddings` | `TogetherClient.Embeddings` | Includes Microsoft.Extensions.AI adapter | +| Rerank | `/rerank` | `TogetherClient.Rerank` | Matches Python/TypeScript clients | +| Files | `/files` | `TogetherClient.Files` | Upload/list/delete supported | +| Fine-tuning & checkpoints | `/fine-tunes` | `TogetherClient.FineTune` | Training limits, cancellation, download helpers | +| Model catalog | `/models` | `TogetherClient.Models` | Lists and describes models | +| Images | `/images/generations` | `TogetherClient.Images` | Added Microsoft.Extensions.AI image adapter | +| Audio speech (text-to-speech) | `/audio/speech` | `TogetherClient.Audio.CreateSpeechAsync` | Streaming + full responses | +| Audio transcription | `/audio/transcriptions` | `TogetherClient.Audio.CreateTranscriptionAsync` | Multipart uploads, verbose JSON | +| Audio translation | `/audio/translations` | `TogetherClient.Audio.CreateTranslationAsync` | Mirrors Python behaviour | +| Evaluation jobs | `/evaluation` & `/evaluations` | `TogetherClient.Evaluation` | Create, list, status, model list | +| Batches | `/batches` | `TogetherClient.Batches` | Create/list/retrieve/cancel | +| Endpoints | `/endpoints` | `TogetherClient.Endpoints` | Create/list/delete dedicated endpoints | +| Hardware catalog | `/hardware` | `TogetherClient.Hardware` | Availability and pricing data | +| Jobs status | `/jobs` | `TogetherClient.Jobs` | List/retrieve background jobs | +| Code interpreter | `/tci/execute` | `TogetherClient.CodeInterpreter` | Handles file uploads and execution | +| Video generation | `/v2/videos` | `TogetherClient.Videos` | Create & retrieve video jobs | +| Microsoft.Extensions.AI (text) | Chat completions | `TogetherAIChatClient` | Implements `IChatClient` | +| Microsoft.Extensions.AI (embeddings) | Embeddings | `TogetherAIEmbeddingGenerator` | Implements `IEmbeddingGenerator>` | +| Microsoft.Extensions.AI (speech-to-text) | Audio transcription | `TogetherAISpeechToTextClient` | Implements `ISpeechToTextClient` | +| Microsoft.Extensions.AI (image generation) | Image generation | `TogetherAIImageClient` | Implements `IChatClient` producing image content | + +Each adapter has unit tests in `Together.Tests/MicrosoftAI` to guard behaviour.