From 743467a97426e7e89a9f8b0c78c50bd6299288cc Mon Sep 17 00:00:00 2001 From: jelveh Date: Mon, 3 Aug 2026 13:15:37 -0700 Subject: [PATCH] feat: add BytePlus ModelArk chat provider Adds BytePlus ModelArk as a provider for the puter-chat-completion driver, following the MiniMax/ZAI providers as reference per doc/contributing-apis.md. - OpenAI-compatible endpoint at ark.ap-southeast.bytepluses.com/api/v3 (apiBaseUrl config selects the region) - Static catalog of 16 chat models (Seed 2.x/1.x incl. vision, GLM, DeepSeek, GPT-OSS) with limits and per-token pricing from the official docs - Passes Ark's thinking/response_format/stop params through custom; normalizes reasoning_content to reasoning - Bare deepseek-v4-* names stay with the first-party DeepSeek provider; BytePlus only claims prefixed aliases - Offline unit tests (mocked SDK against a real test server) plus an env-gated integration test --- config.template.jsonc | 8 + .../ChatCompletionDriver.edges.test.ts | 2 + .../drivers/ai-chat/ChatCompletionDriver.ts | 13 + .../BytePlusProvider.integration.test.ts | 63 ++ .../byteplus/BytePlusProvider.test.ts | 815 ++++++++++++++++++ .../providers/byteplus/BytePlusProvider.ts | 195 +++++ .../ai-chat/providers/byteplus/models.ts | 196 +++++ 7 files changed, 1292 insertions(+) create mode 100644 src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.integration.test.ts create mode 100644 src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.test.ts create mode 100644 src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts create mode 100644 src/backend/drivers/ai-chat/providers/byteplus/models.ts diff --git a/config.template.jsonc b/config.template.jsonc index d80a2d5d7..ca0329d96 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -341,6 +341,14 @@ "apiKey": "", "apiBaseUrl": "https://llm.onerouter.pro/v1" }, + // BytePlus ModelArk. `apiBaseUrl` selects the region; see + // https://docs.byteplus.com/en/docs/ModelArk/1330310 for options. + "byteplus": { + "apiKey": "", + "apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3" + }, + "zai": { "apiKey": "" }, + "alibaba": { "apiKey": "" }, "together-ai": { "apiKey": "" }, // Local Ollama. `enabled: false` skips the auto-probe at startup // (otherwise Puter logs ECONNREFUSED on every boot when no Ollama diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts index 529f9ee41..2bfc1fabe 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts @@ -72,6 +72,7 @@ const FULL_PROVIDER_CONFIG = { 'together-ai': { apiKey: 'k' }, openrouter: { apiKey: 'k', apiBaseUrl: 'https://openrouter.test' }, infron: { apiKey: 'k' }, + byteplus: { apiKey: 'k' }, // Suppress auto-discovery of a developer's local Ollama. ollama: { enabled: false }, }, @@ -176,6 +177,7 @@ describe('ChatCompletionDriver provider registration', () => { 'together-ai', 'openrouter', 'infron', + 'byteplus', 'fake-chat', ]) { expect(providers).toContain(expected); diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 1e009bf04..a8489194d 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -32,6 +32,7 @@ import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js'; import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js'; import { AzureChatProvider } from './providers/azure/AzureChatProvider.js'; import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js'; +import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js'; import { ClaudeProvider } from './providers/claude/ClaudeProvider.js'; import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js'; import { FakeChatProvider } from './providers/FakeChatProvider.js'; @@ -999,6 +1000,18 @@ export class ChatCompletionDriver extends PuterDriver { ); } + const byteplus = providers['byteplus']; + const byteplusKey = readKey(byteplus); + if (byteplusKey) { + this.#providers['byteplus'] = new BytePlusProvider( + { + apiKey: byteplusKey, + apiBaseUrl: byteplus?.apiBaseUrl as string | undefined, + }, + metering, + ); + } + // Fake provider — always available for testing this.#providers['fake-chat'] = new FakeChatProvider(); } diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.integration.test.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.integration.test.ts new file mode 100644 index 000000000..3adcb602e --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.integration.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Integration test for the BytePlus ModelArk provider. + * + * Uses `seed-1-6-flash-250715` with `thinking: disabled` passed through + * `custom`. Ark's seed models default to deep reasoning and route those + * tokens to a `reasoning_content` field, leaving `content` empty under + * tight budgets. Disabling thinking forces a plain text response so the + * usual `message.content` assertion works. Skipped when + * `PUTER_TEST_AI_BYTEPLUS_API_KEY` is unset. + */ + +import { describe, expect, it } from 'vitest'; +import { + INTEGRATION_TEST_TIMEOUT_MS, + makeMeteringStub, + optionalEnv, + skipUnlessEnv, + withTestActor, +} from '../../../integrationTestUtil.js'; +import { BytePlusProvider } from './BytePlusProvider.js'; + +const ENV_VAR = 'PUTER_TEST_AI_BYTEPLUS_API_KEY'; + +describe.skipIf(skipUnlessEnv(ENV_VAR))('BytePlusProvider (integration)', () => { + it('returns a non-empty completion from seed-1-6-flash-250715', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => { + const provider = new BytePlusProvider( + { apiKey: optionalEnv(ENV_VAR)! }, + makeMeteringStub(), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'seed-1-6-flash-250715', + messages: [{ role: 'user', content: 'Say hi in one word.' }], + max_tokens: 16, + custom: { thinking: { type: 'disabled' } }, + }), + ); + + const text = (result as { message?: { content?: string } }).message + ?.content; + expect(typeof text === 'string' && text.length > 0).toBe(true); + }); +}); diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.test.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.test.ts new file mode 100644 index 000000000..a79db29b3 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.test.ts @@ -0,0 +1,815 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Offline unit tests for BytePlusProvider. + * + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock + * redis) and constructs BytePlusProvider directly against the live + * wired `MeteringService` so the recording side is exercised + * end-to-end. The OpenAI SDK is mocked at the module boundary — + * ModelArk is OpenAI-compatible so the provider talks to it through + * the same client — so the provider never reaches the network. The + * companion integration test (BytePlusProvider.integration.test.ts) + * exercises the real ModelArk endpoint. + */ + +import { Writable } from 'node:stream'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; + +import { SYSTEM_ACTOR } from '../../../../core/actor.js'; +import type { MeteringService } from '../../../../services/metering/MeteringService.js'; +import { PuterServer } from '../../../../server.js'; +import { setupTestServer } from '../../../../testUtil.js'; +import { withTestActor } from '../../../integrationTestUtil.js'; +import { AIChatStream } from '../../utils/Streaming.js'; +import { BytePlusProvider } from './BytePlusProvider.js'; +import { BYTEPLUS_MODELS } from './models.js'; + +// ── OpenAI SDK mock ───────────────────────────────────────────────── +// +// `vi.hoisted` lets us share spies between the (hoisted) factory and +// the test body so each test can stub `chat.completions.create` with +// the response shape it cares about. ModelArk uses the OpenAI wire +// shape so the provider talks to it via the OpenAI SDK. + +const { createMock, openAICtor } = vi.hoisted(() => { + const createMock = vi.fn(); + const openAICtor = vi.fn(); + return { createMock, openAICtor }; +}); + +vi.mock('openai', () => { + const OpenAICtor = vi.fn().mockImplementation(function ( + this: Record, + opts: unknown, + ) { + openAICtor(opts); + this.chat = { completions: { create: createMock } }; + }); + // Some providers (e.g. OllamaChatProvider) import the default export + // and access `.OpenAI` on it, so expose the same constructor under + // both shapes — the test server boots every provider, not just + // BytePlus. + return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } }; +}); + +// ── Test harness ──────────────────────────────────────────────────── + +let server: PuterServer; +let recordSpy: MockInstance; + +beforeAll(async () => { + server = await setupTestServer(); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeProvider = ( + config: { apiKey?: string; apiBaseUrl?: string } = {}, +) => { + const provider = new BytePlusProvider( + { + apiKey: config.apiKey ?? 'test-key', + ...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}), + }, + server.services.metering, + ); + return { provider }; +}; + +const asAsyncIterable = (items: T[]): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + for (const item of items) { + yield item; + } + }, +}); + +const makeCapturingChatStream = () => { + const chunks: string[] = []; + const sink = new Writable({ + write(chunk, _enc, cb) { + chunks.push(chunk.toString('utf8')); + cb(); + }, + }); + const chatStream = new AIChatStream({ stream: sink }); + return { + chatStream, + events: () => + chunks + .join('') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)), + }; +}; + +beforeEach(() => { + createMock.mockReset(); + openAICtor.mockReset(); + // Spy on the live MeteringService — we don't replace the impl + // (that would skip the recording side we want covered) but we + // capture the calls the provider makes so per-test assertions + // can verify metering shape. + recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Construction ──────────────────────────────────────────────────── + +describe('BytePlusProvider construction', () => { + it('points the OpenAI SDK at the ModelArk base URL with the configured key', () => { + makeProvider(); + expect(openAICtor).toHaveBeenCalledTimes(1); + expect(openAICtor).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: 'https://ark.ap-southeast.bytepluses.com/api/v3', + }); + }); + + it('honours a custom apiBaseUrl override (e.g. the eu-west region)', () => { + makeProvider({ + apiBaseUrl: 'https://ark.eu-west.bytepluses.com/api/v3', + }); + expect(openAICtor).toHaveBeenCalledWith({ + apiKey: 'test-key', + baseURL: 'https://ark.eu-west.bytepluses.com/api/v3', + }); + }); +}); + +// ── Model catalog ─────────────────────────────────────────────────── + +describe('BytePlusProvider model catalog', () => { + it('returns seed-2-0-lite-260428 as the default', () => { + const { provider } = makeProvider(); + expect(provider.getDefaultModel()).toBe('seed-2-0-lite-260428'); + }); + + it('exposes the static BYTEPLUS_MODELS list verbatim from models()', () => { + const { provider } = makeProvider(); + expect(provider.models()).toBe(BYTEPLUS_MODELS); + }); + + it('list() flattens canonical ids and aliases', () => { + const { provider } = makeProvider(); + const names = provider.list(); + for (const m of BYTEPLUS_MODELS) { + expect(names).toContain(m.id); + for (const a of m.aliases ?? []) { + expect(names).toContain(a); + } + } + // Sanity: undated series aliases resolve alongside canonical ids. + expect(names).toContain('seed-1-6'); + expect(names).toContain('byteplus/seed-1-6'); + expect(names).toContain('byteplus/seed-1-6-250915'); + }); + + it('never claims the bare deepseek-v4 names owned by the DeepSeek provider', () => { + const { provider } = makeProvider(); + const names = provider.list(); + expect(names).not.toContain('deepseek-v4-pro'); + expect(names).not.toContain('deepseek-v4-flash'); + expect(names).toContain('byteplus/deepseek-v4-pro'); + expect(names).toContain('byteplus/deepseek-v4-flash'); + }); +}); + +// ── Request shape ─────────────────────────────────────────────────── + +describe('BytePlusProvider.complete request shape', () => { + const baseCompletion = { + choices: [ + { + message: { content: 'hi', role: 'assistant' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + + it('forwards model, messages, and bare-bones request without optional knobs', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.model).toBe('seed-1-6-250915'); + expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]); + // Optional generation knobs should be absent unless supplied. + expect('max_tokens' in args).toBe(false); + expect('temperature' in args).toBe(false); + expect('top_p' in args).toBe(false); + expect('tools' in args).toBe(false); + expect('tool_choice' in args).toBe(false); + expect('thinking' in args).toBe(false); + }); + + it('forwards max_tokens, temperature, top_p, tools, and tool_choice when supplied', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + const tools = [ + { + type: 'function', + function: { + name: 'lookup', + description: 'find a thing', + parameters: { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }, + }, + }, + ]; + + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 256, + temperature: 0.4, + top_p: 0.9, + tools, + tool_choice: 'auto', + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.max_tokens).toBe(256); + expect(args.temperature).toBe(0.4); + expect(args.top_p).toBe(0.9); + expect(args.tools).toBe(tools); + expect(args.tool_choice).toBe('auto'); + }); + + it('forwards Ark-specific custom params (thinking, stop, response_format)', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + custom: { + thinking: { type: 'disabled' }, + stop: ['\n\n'], + response_format: { type: 'json_object' }, + }, + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.thinking).toEqual({ type: 'disabled' }); + expect(args.stop).toEqual(['\n\n']); + expect(args.response_format).toEqual({ type: 'json_object' }); + }); + + it('strips Anthropic-style cache_control from messages before sending', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [ + { + role: 'user', + content: 'hi', + cache_control: { type: 'ephemeral' }, + } as unknown as { role: string; content: string }, + ], + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect('cache_control' in args.messages[0]).toBe(false); + }); + + it('only sets stream_options.include_usage when streaming', async () => { + const { provider } = makeProvider(); + // Non-stream path. + createMock.mockResolvedValueOnce(baseCompletion); + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + stream: false, + }), + ); + const [nonStreamArgs] = createMock.mock.calls[0]!; + expect(nonStreamArgs.stream).toBe(false); + expect('stream_options' in nonStreamArgs).toBe(false); + + // Stream path. + createMock.mockReturnValueOnce(asAsyncIterable([])); + await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + }), + ); + const [streamArgs] = createMock.mock.calls[1]!; + expect(streamArgs.stream).toBe(true); + expect(streamArgs.stream_options).toEqual({ include_usage: true }); + }); +}); + +// ── Model resolution ──────────────────────────────────────────────── + +describe('BytePlusProvider model resolution', () => { + const baseCompletion = { + choices: [ + { + message: { content: 'ok', role: 'assistant' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + + it('resolves an exact canonical id', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'glm-4-7-251222', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + expect(createMock.mock.calls[0]![0].model).toBe('glm-4-7-251222'); + expect(recordSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.anything(), + 'byteplus:glm-4-7-251222', + expect.any(Object), + ); + }); + + it('resolves an undated series alias to the newest snapshot (alias rewriting)', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'seed-1-6', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + // The wire model should be the canonical dated id, not the alias. + expect(createMock.mock.calls[0]![0].model).toBe('seed-1-6-250915'); + expect(recordSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.anything(), + 'byteplus:seed-1-6-250915', + expect.any(Object), + ); + }); + + it('falls back to the default model when given an unknown id', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'totally-not-a-real-model', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + expect(createMock.mock.calls[0]![0].model).toBe( + 'seed-2-0-lite-260428', + ); + }); +}); + +// ── Non-stream completion + reasoning_content normalisation ───────── + +describe('BytePlusProvider.complete non-stream output', () => { + it('returns the first choice and runs the metered usage calculator', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { content: 'hi there', role: 'assistant' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 10 }, + }, + }); + + const result = await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + expect(result).toMatchObject({ + message: { content: 'hi there', role: 'assistant' }, + finish_reason: 'stop', + }); + expect((result as { usage: unknown }).usage).toEqual({ + prompt_tokens: 100, + completion_tokens: 50, + cached_tokens: 10, + }); + + // Cost overrides scale per-token usage by the per-token cents from + // the model's costs table, so derive expectations from + // BYTEPLUS_MODELS directly to avoid hardcoded float-precision drift. + const seed16 = BYTEPLUS_MODELS.find( + (m) => m.id === 'seed-1-6-250915', + )!; + expect(recordSpy).toHaveBeenCalledTimes(1); + const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!; + expect(usage).toEqual({ + prompt_tokens: 100, + completion_tokens: 50, + cached_tokens: 10, + }); + expect(actor).toBe(SYSTEM_ACTOR); + expect(prefix).toBe('byteplus:seed-1-6-250915'); + expect(overrides.prompt_tokens).toBeCloseTo( + 100 * Number(seed16.costs.prompt_tokens), + 5, + ); + expect(overrides.completion_tokens).toBeCloseTo( + 50 * Number(seed16.costs.completion_tokens), + 5, + ); + expect(overrides.cached_tokens).toBeCloseTo( + 10 * Number(seed16.costs.cached_tokens ?? 0), + 5, + ); + }); + + it('preserves OpenAI-shaped tool_calls on the assistant response', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"q":"puter"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'do a tool call' }], + tools: [ + { + type: 'function', + function: { name: 'lookup', parameters: {} }, + }, + ], + }), + )) as { message: { tool_calls?: unknown[] }; finish_reason: string }; + + expect(result.finish_reason).toBe('tool_calls'); + expect(result.message.tool_calls).toEqual([ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{"q":"puter"}', + }, + }, + ]); + }); + + it('renames Ark `reasoning_content` to `reasoning` on the message', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: 'final answer', + reasoning_content: 'thinking out loud', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record }; + + expect(result.message.reasoning).toBe('thinking out loud'); + expect('reasoning_content' in result.message).toBe(false); + }); + + it('does not overwrite an existing `reasoning` field if both are present', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: 'final', + reasoning: 'original', + reasoning_content: 'should-be-dropped', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record }; + + expect(result.message.reasoning).toBe('original'); + expect('reasoning_content' in result.message).toBe(false); + }); +}); + +// ── Streaming deltas ──────────────────────────────────────────────── + +describe('BytePlusProvider.complete streaming', () => { + it('streams text deltas through to text events and meters final usage', async () => { + const { provider } = makeProvider(); + createMock.mockReturnValueOnce( + asAsyncIterable([ + { choices: [{ delta: { content: 'hel' } }] }, + { choices: [{ delta: { content: 'lo' } }] }, + { + choices: [{ delta: {} }], + usage: { + prompt_tokens: 4, + completion_tokens: 2, + prompt_tokens_details: { cached_tokens: 1 }, + }, + }, + ]), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'say hi' }], + stream: true, + }), + ); + expect((result as { stream: boolean }).stream).toBe(true); + + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { chatStream: unknown }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + + const events = harness.events(); + const textEvents = events.filter((e) => e.type === 'text'); + expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']); + + const usageEvent = events.find((e) => e.type === 'usage'); + expect(usageEvent?.usage).toEqual({ + prompt_tokens: 4, + completion_tokens: 2, + cached_tokens: 1, + }); + + const seed16 = BYTEPLUS_MODELS.find( + (m) => m.id === 'seed-1-6-250915', + )!; + expect(recordSpy).toHaveBeenCalledTimes(1); + const [, , prefix, overrides] = recordSpy.mock.calls[0]!; + expect(prefix).toBe('byteplus:seed-1-6-250915'); + expect(overrides.prompt_tokens).toBeCloseTo( + 4 * Number(seed16.costs.prompt_tokens), + 5, + ); + expect(overrides.completion_tokens).toBeCloseTo( + 2 * Number(seed16.costs.completion_tokens), + 5, + ); + expect(overrides.cached_tokens).toBeCloseTo( + 1 * Number(seed16.costs.cached_tokens ?? 0), + 5, + ); + }); + + it('streams reasoning_content deltas as reasoning events', async () => { + const { provider } = makeProvider(); + createMock.mockReturnValueOnce( + asAsyncIterable([ + { + choices: [ + { delta: { reasoning_content: 'pondering...' } }, + ], + }, + { choices: [{ delta: { content: 'answer' } }] }, + { + choices: [{ delta: {} }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + ]), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'think hard' }], + stream: true, + }), + ); + + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { chatStream: unknown }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + + const events = harness.events(); + const reasoningEvents = events.filter((e) => e.type === 'reasoning'); + expect(reasoningEvents.map((e) => e.reasoning)).toEqual([ + 'pondering...', + ]); + const textEvents = events.filter((e) => e.type === 'text'); + expect(textEvents.map((e) => e.text)).toEqual(['answer']); + }); + + it('builds a tool_use block from streamed function-call deltas', async () => { + const { provider } = makeProvider(); + createMock.mockReturnValueOnce( + asAsyncIterable([ + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { + name: 'lookup', + arguments: '{"q":', + }, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '"puter"}' }, + }, + ], + }, + }, + ], + }, + { + choices: [{ delta: {} }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + ]), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'do tool call' }], + tools: [ + { + type: 'function', + function: { name: 'lookup', parameters: {} }, + }, + ], + stream: true, + }), + ); + + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { chatStream: unknown }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + + const events = harness.events(); + const toolEvent = events.find((e) => e.type === 'tool_use'); + expect(toolEvent).toBeDefined(); + expect(toolEvent?.id).toBe('call_1'); + expect(toolEvent?.name).toBe('lookup'); + expect(toolEvent?.input).toEqual({ q: 'puter' }); + }); +}); + +// ── Error mapping ─────────────────────────────────────────────────── + +describe('BytePlusProvider.complete error mapping', () => { + it('rethrows errors raised by the OpenAI client unchanged', async () => { + const { provider } = makeProvider(); + const apiError = new Error('ModelArk exploded'); + createMock.mockRejectedValueOnce(apiError); + + await expect( + withTestActor(() => + provider.complete({ + model: 'seed-1-6-250915', + messages: [{ role: 'user', content: 'boom' }], + }), + ), + ).rejects.toBe(apiError); + + // No metering should be recorded on a failed call. + expect(recordSpy).not.toHaveBeenCalled(); + }); +}); + +// ── Moderation ────────────────────────────────────────────────────── + +describe('BytePlusProvider.checkModeration', () => { + it('throws — BytePlus provider does not implement moderation', () => { + const { provider } = makeProvider(); + expect(() => provider.checkModeration('anything')).toThrow( + /not implemented/i, + ); + }); +}); diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts new file mode 100644 index 000000000..fab9e3f06 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { OpenAI } from 'openai'; +import { ChatCompletionCreateParams } from 'openai/resources/index.js'; +import { Context } from '../../../../core/context.js'; +import type { MeteringService } from '../../../../services/metering/MeteringService.js'; +import type { IChatProvider, ICompleteArguments } from '../../types.js'; +import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; +import { BYTEPLUS_MODELS } from './models.js'; + +type BytePlusConfig = { + apiKey: string; + apiBaseUrl?: string; +}; + +type BytePlusCustomParams = { + response_format?: unknown; + stop?: string[]; + // Ark-specific toggle for deep reasoning; the seed models reason by + // default and route those tokens to `reasoning_content`. + thinking?: { + type?: 'enabled' | 'disabled' | 'auto'; + }; +}; + +const asRecord = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + +/** + * BytePlus ModelArk provider — an OpenAI-compatible endpoint serving + * ByteDance's Seed models plus hosted third-party models (GLM, DeepSeek, + * GPT-OSS). https://docs.byteplus.com/en/docs/ModelArk/1330626 + */ +export class BytePlusProvider implements IChatProvider { + #openai: OpenAI; + + #meteringService: MeteringService; + + #defaultModel = 'seed-2-0-lite-260428'; + + constructor(config: BytePlusConfig, meteringService: MeteringService) { + this.#openai = new OpenAI({ + apiKey: config.apiKey, + baseURL: + config.apiBaseUrl ?? + 'https://ark.ap-southeast.bytepluses.com/api/v3', + }); + this.#meteringService = meteringService; + } + + getDefaultModel() { + return this.#defaultModel; + } + + models() { + return BYTEPLUS_MODELS; + } + + list() { + const modelIds: string[] = []; + for (const model of this.models()) { + modelIds.push(model.id); + if (model.aliases) { + modelIds.push(...model.aliases); + } + } + return modelIds; + } + + async complete( + params: ICompleteArguments, + ): ReturnType { + const { + custom, + max_tokens, + stream, + temperature, + tools, + tool_choice, + top_p, + } = params; + let { messages, model } = params; + const actor = Context.get('actor'); + const availableModels = this.models(); + const modelUsed = + availableModels.find((m) => + [m.id, ...(m.aliases || [])].includes(model), + ) || availableModels.find((m) => m.id === this.getDefaultModel())!; + + messages = await OpenAIUtil.process_input_messages(messages); + messages = messages.map((message) => { + delete message.cache_control; + return message; + }); + + const customParams = asRecord(custom) as BytePlusCustomParams; + + const completionParams: ChatCompletionCreateParams = { + messages, + model: modelUsed.id, + ...(tools ? { tools } : {}), + ...(tool_choice !== undefined ? { tool_choice } : {}), + ...(max_tokens !== undefined ? { max_tokens } : {}), + ...(temperature !== undefined ? { temperature } : {}), + ...(top_p !== undefined ? { top_p } : {}), + ...(customParams.response_format + ? { response_format: customParams.response_format } + : {}), + ...(customParams.stop ? { stop: customParams.stop } : {}), + ...(customParams.thinking + ? { thinking: customParams.thinking } + : {}), + stream: !!stream, + ...(stream + ? { + stream_options: { include_usage: true }, + } + : {}), + } as ChatCompletionCreateParams; + + const completion = + await this.#openai.chat.completions.create(completionParams); + + const result = await OpenAIUtil.handle_completion_output({ + usage_calculator: ({ usage }) => { + const trackedUsage = usage + ? OpenAIUtil.extractMeteredUsage(usage) + : { + prompt_tokens: 0, + completion_tokens: 0, + cached_tokens: 0, + }; + const costsOverride = Object.fromEntries( + Object.entries(trackedUsage).map(([key, value]) => { + return [key, value * Number(modelUsed.costs[key] ?? 0)]; + }), + ); + this.#meteringService.utilRecordUsageObject( + trackedUsage, + actor!, + `byteplus:${modelUsed.id}`, + costsOverride, + ); + return trackedUsage; + }, + stream, + completion, + }); + + this.#normalizeReasoningContent(result); + return result; + } + + checkModeration( + _text: string, + ): ReturnType { + throw new Error('Method not implemented.'); + } + + // Ark's deep-reasoning models return `reasoning_content` (DeepSeek wire + // convention); expose it under the `reasoning` key like other providers. + #normalizeReasoningContent( + result: Awaited>, + ) { + if (!('message' in result) || !result.message) return; + + const message = result.message as Record; + if ( + message.reasoning === undefined && + message.reasoning_content !== undefined + ) { + message.reasoning = message.reasoning_content; + } + delete message.reasoning_content; + } +} diff --git a/src/backend/drivers/ai-chat/providers/byteplus/models.ts b/src/backend/drivers/ai-chat/providers/byteplus/models.ts new file mode 100644 index 000000000..9a790626c --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/byteplus/models.ts @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { IChatModel } from '../../types.js'; +import { usdPerMToken } from '../../utils/pricing.js'; + +const K = 1_000; + +const bytePlusModel = ( + id: string, + name: string, + context: number, + maxTokens: number, + costs: IChatModel['costs'], + opts: { + input?: string[]; + extraAliases?: string[]; + openWeights?: boolean; + } = {}, +): IChatModel => ({ + puterId: `byteplus:byteplus/${id}`, + id, + name, + aliases: [`byteplus/${id}`, ...(opts.extraAliases ?? [])], + modalities: { input: opts.input ?? ['text'], output: ['text'] }, + open_weights: opts.openWeights ?? false, + tool_call: true, + context, + max_tokens: maxTokens, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs, +}); + +// An undated series alias (e.g. `seed-1-6`) points at the newest snapshot of +// that series, mirroring how other providers alias their rolling names. +const series = (alias: string) => [alias, `byteplus/${alias}`]; + +const VISION = ['text', 'image', 'video']; + +// Hardcoded from https://docs.byteplus.com/en/docs/ModelArk/1330310 (catalog) +// and https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing). +// +// Some models bill a higher rate for prompts above 128K tokens; the costs +// below are the base tier. seed-2-0-lite/mini-260428 also accept audio input +// at a much higher per-token rate ModelArk folds into prompt_tokens, so audio +// is deliberately left out of `modalities` to keep metering honest. +export const BYTEPLUS_MODELS: IChatModel[] = [ + bytePlusModel( + 'dola-seed-2-1-turbo-260628', + 'Dola Seed 2.1 Turbo', + 256 * K, + 256 * K, + usdPerMToken(0.5, 2.5, 0.1), + { input: VISION, extraAliases: series('dola-seed-2-1-turbo') }, + ), + bytePlusModel( + 'seed-2-0-lite-260428', + 'Seed 2.0 Lite', + 256 * K, + 128 * K, + usdPerMToken(0.25, 2, 0.05), + { input: VISION, extraAliases: series('seed-2-0-lite') }, + ), + bytePlusModel( + 'seed-2-0-lite-260228', + 'Seed 2.0 Lite', + 256 * K, + 128 * K, + usdPerMToken(0.25, 2, 0.05), + { input: VISION }, + ), + bytePlusModel( + 'seed-2-0-mini-260428', + 'Seed 2.0 Mini', + 256 * K, + 128 * K, + usdPerMToken(0.1, 0.4, 0.02), + { input: VISION, extraAliases: series('seed-2-0-mini') }, + ), + bytePlusModel( + 'seed-2-0-mini-260215', + 'Seed 2.0 Mini', + 256 * K, + 128 * K, + usdPerMToken(0.1, 0.4, 0.02), + { input: VISION }, + ), + bytePlusModel( + 'seed-2-0-pro-260328', + 'Seed 2.0 Pro', + 256 * K, + 128 * K, + usdPerMToken(0.5, 3, 0.1), + { input: VISION, extraAliases: series('seed-2-0-pro') }, + ), + bytePlusModel( + 'seed-2-0-code-preview-260328', + 'Seed 2.0 Code Preview', + 256 * K, + 128 * K, + usdPerMToken(0.5, 3, 0.1), + { input: VISION, extraAliases: series('seed-2-0-code-preview') }, + ), + bytePlusModel( + 'seed-1-8-251228', + 'Seed 1.8', + 256 * K, + 64 * K, + usdPerMToken(0.25, 2, 0.05), + { input: VISION, extraAliases: series('seed-1-8') }, + ), + bytePlusModel( + 'seed-1-6-250915', + 'Seed 1.6', + 256 * K, + 32 * K, + usdPerMToken(0.25, 2, 0.05), + { input: VISION, extraAliases: series('seed-1-6') }, + ), + bytePlusModel( + 'seed-1-6-flash-250715', + 'Seed 1.6 Flash', + 256 * K, + 32 * K, + usdPerMToken(0.075, 0.3, 0.015), + { input: VISION, extraAliases: series('seed-1-6-flash') }, + ), + bytePlusModel( + 'glm-5-2-260617', + 'GLM-5.2', + 1_024 * K, + 128 * K, + usdPerMToken(1.4, 4.4, 0.26), + { extraAliases: series('glm-5-2') }, + ), + bytePlusModel( + 'glm-4-7-251222', + 'GLM-4.7', + 256 * K, + 128 * K, + usdPerMToken(0.6, 2.2, 0.11), + { extraAliases: series('glm-4-7') }, + ), + // The bare `deepseek-v4-*` names belong to the first-party DeepSeek + // provider, so these only carry the byteplus-prefixed aliases. + bytePlusModel( + 'deepseek-v4-pro-260425', + 'DeepSeek V4 Pro', + 1_024 * K, + 384 * K, + usdPerMToken(1.74, 3.48, 0.145), + { extraAliases: ['byteplus/deepseek-v4-pro'] }, + ), + bytePlusModel( + 'deepseek-v4-flash-260425', + 'DeepSeek V4 Flash', + 1_024 * K, + 384 * K, + usdPerMToken(0.14, 0.28, 0.028), + { extraAliases: ['byteplus/deepseek-v4-flash'] }, + ), + bytePlusModel( + 'deepseek-v3-2-251201', + 'DeepSeek V3.2', + 128 * K, + 32 * K, + usdPerMToken(0.28, 0.42, 0.056), + { extraAliases: series('deepseek-v3-2') }, + ), + bytePlusModel( + 'gpt-oss-120b-250805', + 'GPT-OSS 120B', + 128 * K, + 64 * K, + usdPerMToken(0.1, 0.5), + { extraAliases: series('gpt-oss-120b'), openWeights: true }, + ), +];