mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
Add Neuralwatt support (#3509)
This commit is contained in:
@@ -48,6 +48,7 @@ import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
|
||||
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
@@ -72,6 +73,7 @@ const FULL_PROVIDER_CONFIG = {
|
||||
'together-ai': { apiKey: 'k' },
|
||||
openrouter: { apiKey: 'k', apiBaseUrl: 'https://openrouter.test' },
|
||||
infron: { apiKey: 'k' },
|
||||
neuralwatt: { apiKey: 'k' },
|
||||
// Suppress auto-discovery of a developer's local Ollama.
|
||||
ollama: { enabled: false },
|
||||
},
|
||||
@@ -120,6 +122,9 @@ beforeAll(async () => {
|
||||
vi.spyOn(InfronProvider.prototype, 'models').mockResolvedValue(
|
||||
aggregatorCatalog('infron-only-model') as never,
|
||||
);
|
||||
vi.spyOn(NeuralwattProvider.prototype, 'models').mockResolvedValue(
|
||||
aggregatorCatalog('neuralwatt-only-model') as never,
|
||||
);
|
||||
fullDriver = await makeDriver(FULL_PROVIDER_CONFIG);
|
||||
fakeOnlyDriver = await makeDriver({
|
||||
providers: { ollama: { enabled: false } },
|
||||
@@ -176,6 +181,7 @@ describe('ChatCompletionDriver provider registration', () => {
|
||||
'together-ai',
|
||||
'openrouter',
|
||||
'infron',
|
||||
'neuralwatt',
|
||||
'fake-chat',
|
||||
]) {
|
||||
expect(providers).toContain(expected);
|
||||
|
||||
@@ -41,6 +41,7 @@ import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
|
||||
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
|
||||
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
|
||||
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
|
||||
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
|
||||
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenAiResponsesChatProvider } from './providers/openai/OpenAiChatResponsesProvider.js';
|
||||
@@ -999,6 +1000,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const neuralwatt = providers['neuralwatt'];
|
||||
const neuralwattKey = readKey(neuralwatt);
|
||||
if (neuralwattKey) {
|
||||
this.#providers['neuralwatt'] = new NeuralwattProvider(
|
||||
{
|
||||
apiKey: neuralwattKey,
|
||||
apiBaseUrl: neuralwatt?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
}
|
||||
|
||||
// Fake provider — always available for testing
|
||||
this.#providers['fake-chat'] = new FakeChatProvider();
|
||||
}
|
||||
@@ -1006,7 +1019,12 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// -- Model map ---------------------------------------------------
|
||||
|
||||
async #buildModelMap() {
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter', 'infron']);
|
||||
const AGGREGATORS = new Set([
|
||||
'together-ai',
|
||||
'openrouter',
|
||||
'infron',
|
||||
'neuralwatt',
|
||||
]);
|
||||
|
||||
for (const providerName in this.#providers) {
|
||||
const provider = this.#providers[providerName];
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for NeuralwattProvider.
|
||||
*
|
||||
* Boots a real PuterServer and constructs NeuralwattProvider against the
|
||||
* live MeteringService. The OpenAI SDK and axios (catalog + quota) are
|
||||
* mocked at their module boundaries.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { kv } from '../../../../util/kvSingleton.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import {
|
||||
mapNeuralwattApiModel,
|
||||
NEURALWATT_DEFAULT_MODEL,
|
||||
stripNeuralwattPrefix,
|
||||
} from './models.js';
|
||||
import { NeuralwattProvider } from './NeuralwattProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { createMock, openAICtor } = vi.hoisted(() => ({
|
||||
createMock: vi.fn(),
|
||||
openAICtor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
opts: unknown,
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.chat = { completions: { create: createMock } };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
});
|
||||
|
||||
// ── axios mock (models + quota) ─────────────────────────────────────
|
||||
|
||||
const { axiosRequestMock } = vi.hoisted(() => ({
|
||||
axiosRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: { request: axiosRequestMock },
|
||||
request: axiosRequestMock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
const KV_MODELS_KEY = 'neuralwattChat:models';
|
||||
const KV_QUOTA_KEY = 'neuralwattChat:quota';
|
||||
|
||||
const SAMPLE_API_MODELS = [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
created: 1_700_000_000,
|
||||
max_model_len: 1_000_000,
|
||||
metadata: {
|
||||
display_name: 'DeepSeek V4 Flash',
|
||||
description: 'Fast tool-calling model',
|
||||
pricing: {
|
||||
input_per_million: 0.14,
|
||||
output_per_million: 0.28,
|
||||
cached_input_per_million: 0.014,
|
||||
pricing_tbd: false,
|
||||
},
|
||||
capabilities: {
|
||||
tools: true,
|
||||
vision: false,
|
||||
streaming: true,
|
||||
},
|
||||
limits: {
|
||||
max_context_length: 1_000_000,
|
||||
max_output_tokens: 384_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zai-org/GLM-5.1-FP8',
|
||||
metadata: {
|
||||
display_name: 'GLM 5.1',
|
||||
pricing: {
|
||||
input_per_million: 0.35,
|
||||
output_per_million: 1.38,
|
||||
cached_input_per_million: 0.035,
|
||||
pricing_tbd: false,
|
||||
},
|
||||
capabilities: {
|
||||
tools: true,
|
||||
vision: false,
|
||||
reasoning: true,
|
||||
},
|
||||
limits: {
|
||||
max_context_length: 202_752,
|
||||
max_output_tokens: 16_384,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'coming-soon-model',
|
||||
metadata: {
|
||||
display_name: 'Coming Soon',
|
||||
pricing: {
|
||||
input_per_million: 0,
|
||||
output_per_million: 0,
|
||||
pricing_tbd: true,
|
||||
},
|
||||
capabilities: { tools: true },
|
||||
limits: { max_context_length: 8_000, max_output_tokens: 1_000 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deprecated-model',
|
||||
metadata: {
|
||||
display_name: 'Old',
|
||||
deprecated: true,
|
||||
pricing: {
|
||||
input_per_million: 1,
|
||||
output_per_million: 2,
|
||||
pricing_tbd: false,
|
||||
},
|
||||
capabilities: { tools: true },
|
||||
limits: { max_context_length: 8_000, max_output_tokens: 1_000 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockCatalogAndQuota = (
|
||||
accountingMethod: 'energy' | 'token' = 'energy',
|
||||
) => {
|
||||
axiosRequestMock.mockImplementation(async (opts: { url?: string }) => {
|
||||
if (opts.url?.endsWith('/quota')) {
|
||||
return {
|
||||
data: {
|
||||
balance: { accounting_method: accountingMethod },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { data: SAMPLE_API_MODELS } };
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = (config?: { apiBaseUrl?: string }) => {
|
||||
const provider = new NeuralwattProvider(
|
||||
{ apiKey: 'test-key', ...config },
|
||||
server.services.metering,
|
||||
);
|
||||
return { provider };
|
||||
};
|
||||
|
||||
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
|
||||
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();
|
||||
axiosRequestMock.mockReset();
|
||||
mockCatalogAndQuota('energy');
|
||||
kv.del(KV_MODELS_KEY);
|
||||
kv.del(KV_QUOTA_KEY);
|
||||
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
kv.del(KV_MODELS_KEY);
|
||||
kv.del(KV_QUOTA_KEY);
|
||||
});
|
||||
|
||||
// ── Mapping helpers ─────────────────────────────────────────────────
|
||||
|
||||
describe('Neuralwatt model mapping helpers', () => {
|
||||
it('strips the neuralwatt: prefix for upstream ids', () => {
|
||||
expect(stripNeuralwattPrefix('neuralwatt:deepseek-v4-flash')).toBe(
|
||||
'deepseek-v4-flash',
|
||||
);
|
||||
expect(stripNeuralwattPrefix('deepseek-v4-flash')).toBe(
|
||||
'deepseek-v4-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps catalog pricing into usd-cents cost keys and skips pricing_tbd', () => {
|
||||
const mapped = mapNeuralwattApiModel(SAMPLE_API_MODELS[0]!);
|
||||
expect(mapped).toMatchObject({
|
||||
id: 'neuralwatt:deepseek-v4-flash',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 0.14 * 100,
|
||||
completion_tokens: 0.28 * 100,
|
||||
cached_tokens: 0.014 * 100,
|
||||
},
|
||||
tool_call: true,
|
||||
max_tokens: 384_000,
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
});
|
||||
expect(mapNeuralwattApiModel(SAMPLE_API_MODELS[2]!)).toBeNull();
|
||||
});
|
||||
|
||||
it('marks vision models from capabilities.vision', () => {
|
||||
const mapped = mapNeuralwattApiModel({
|
||||
id: 'gemma-4-31b',
|
||||
metadata: {
|
||||
display_name: 'Gemma 4 31B',
|
||||
pricing: {
|
||||
input_per_million: 0.1,
|
||||
output_per_million: 0.2,
|
||||
pricing_tbd: false,
|
||||
},
|
||||
capabilities: { tools: true, vision: true },
|
||||
limits: {
|
||||
max_context_length: 256_000,
|
||||
max_output_tokens: 16_384,
|
||||
max_images: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mapped).toMatchObject({
|
||||
id: 'neuralwatt:gemma-4-31b',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
max_images: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('NeuralwattProvider construction', () => {
|
||||
it('points the OpenAI SDK at the Neuralwatt base URL with the configured key', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledTimes(1);
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://api.neuralwatt.com/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an apiBaseUrl override', () => {
|
||||
makeProvider({ apiBaseUrl: 'https://custom.neuralwatt.example/v1' });
|
||||
expect(openAICtor).toHaveBeenLastCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://custom.neuralwatt.example/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog + quota ───────────────────────────────────────────
|
||||
|
||||
describe('NeuralwattProvider model catalog', () => {
|
||||
it('returns the neuralwatt-prefixed default model id', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe(NEURALWATT_DEFAULT_MODEL);
|
||||
});
|
||||
|
||||
it('sends the API key as a bearer token on the catalog fetch', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
const modelsCall = axiosRequestMock.mock.calls.find(
|
||||
([args]) =>
|
||||
typeof args?.url === 'string' && args.url.endsWith('/models'),
|
||||
);
|
||||
expect(modelsCall?.[0]).toMatchObject({
|
||||
url: 'https://api.neuralwatt.com/v1/models',
|
||||
headers: { Authorization: 'Bearer test-key' },
|
||||
});
|
||||
});
|
||||
|
||||
it('list() prefixes ids and skips deprecated / pricing_tbd entries', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
expect(ids).toContain('neuralwatt:deepseek-v4-flash');
|
||||
expect(ids).toContain('neuralwatt:zai-org/GLM-5.1-FP8');
|
||||
expect(ids).toContain('GLM-5.1-FP8');
|
||||
expect(ids).not.toContain('neuralwatt:coming-soon-model');
|
||||
expect(ids).not.toContain('neuralwatt:deprecated-model');
|
||||
});
|
||||
|
||||
it('caches the model list in kv after the first axios round-trip', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
await provider.models();
|
||||
const modelsCalls = axiosRequestMock.mock.calls.filter(
|
||||
([args]) =>
|
||||
typeof args?.url === 'string' && args.url.endsWith('/models'),
|
||||
);
|
||||
expect(modelsCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caches accounting_method from /quota', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(provider.getAccountingMethod()).resolves.toBe('energy');
|
||||
await expect(provider.getAccountingMethod()).resolves.toBe('energy');
|
||||
const quotaCalls = axiosRequestMock.mock.calls.filter(
|
||||
([args]) =>
|
||||
typeof args?.url === 'string' && args.url.endsWith('/quota'),
|
||||
);
|
||||
expect(quotaCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('NeuralwattProvider.complete request shape', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
cost: { request_cost_usd: 0 },
|
||||
energy: {
|
||||
energy_kwh: 0.000001,
|
||||
energy_joules: 3.6,
|
||||
measurement_available: true,
|
||||
},
|
||||
};
|
||||
|
||||
it('strips the neuralwatt: prefix from the wire model id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('deepseek-v4-flash');
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].stream).toBe(false);
|
||||
expect('stream_options' in createMock.mock.calls[0]![0]).toBe(false);
|
||||
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[1]![0].stream_options).toEqual({
|
||||
include_usage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects image content on a text-only catalog model', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/cat.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('does not support image input'),
|
||||
});
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers a vision catalog model when the prompt has images and no model was named', async () => {
|
||||
// Seed a vision model into the catalog payload.
|
||||
axiosRequestMock.mockImplementation(async (opts: { url?: string }) => {
|
||||
if (opts.url?.endsWith('/quota')) {
|
||||
return {
|
||||
data: { balance: { accounting_method: 'energy' } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
...SAMPLE_API_MODELS,
|
||||
{
|
||||
id: 'gemma-4-31b',
|
||||
metadata: {
|
||||
display_name: 'Gemma 4 31B',
|
||||
pricing: {
|
||||
input_per_million: 0.1,
|
||||
output_per_million: 0.2,
|
||||
pricing_tbd: false,
|
||||
},
|
||||
capabilities: {
|
||||
tools: true,
|
||||
vision: true,
|
||||
},
|
||||
limits: {
|
||||
max_context_length: 256_000,
|
||||
max_output_tokens: 16_384,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'a cat', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
cost: { request_cost_usd: 0 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: '',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,abc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('gemma-4-31b');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream metering ─────────────────────────────────────────────
|
||||
|
||||
describe('NeuralwattProvider.complete non-stream output', () => {
|
||||
it('bills from cost.request_cost_usd and records energy units at zero cost', 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 },
|
||||
},
|
||||
cost: { request_cost_usd: 0.0001 },
|
||||
energy: {
|
||||
energy_kwh: 0.00000145,
|
||||
energy_joules: 5.23,
|
||||
measurement_available: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
)) as { usage: Record<string, number | string> };
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('neuralwatt:deepseek-v4-flash');
|
||||
expect(usage).toMatchObject({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
energy_kwh: 0.00000145,
|
||||
energy_joules: 5.23,
|
||||
billedUsage: 1,
|
||||
});
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
energy_kwh: 0,
|
||||
energy_joules: 0,
|
||||
billedUsage: 0.0001 * 100_000_000,
|
||||
});
|
||||
expect(result.usage.usd_cents).toBe(0.0001 * 100);
|
||||
expect(result.usage.accounting_method).toBe('energy');
|
||||
});
|
||||
|
||||
it('falls back to catalog token pricing when request_cost_usd is absent', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
// usdPerMToken: $0.14/M → 14 cents per MTok unit in costs map
|
||||
const promptRate = 0.14 * 100;
|
||||
const completionRate = 0.28 * 100;
|
||||
const cachedRate = 0.014 * 100;
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toMatchObject({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 100 * promptRate,
|
||||
completion_tokens: 50 * completionRate,
|
||||
cached_tokens: 10 * cachedRate,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming ───────────────────────────────────────────────────────
|
||||
|
||||
describe('NeuralwattProvider.complete streaming', () => {
|
||||
it('streams text deltas and meters final-chunk cost + energy', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: { content: 'Hello' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: { content: '!' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 2,
|
||||
},
|
||||
cost: { request_cost_usd: 0.00005 },
|
||||
energy: {
|
||||
energy_kwh: 0.000002,
|
||||
energy_joules: 7.2,
|
||||
measurement_available: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'neuralwatt:deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
)) as {
|
||||
stream: true;
|
||||
init_chat_stream: (p: {
|
||||
chatStream: AIChatStream;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
const { chatStream, events } = makeCapturingChatStream();
|
||||
await result.init_chat_stream({ chatStream });
|
||||
|
||||
const textEvents = events().filter((e) => e.type === 'text');
|
||||
expect(textEvents.map((e) => e.text).join('')).toBe('Hello!');
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toMatchObject({
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 2,
|
||||
energy_kwh: 0.000002,
|
||||
energy_joules: 7.2,
|
||||
billedUsage: 1,
|
||||
});
|
||||
expect(overrides).toMatchObject({
|
||||
billedUsage: 0.00005 * 100_000_000,
|
||||
energy_kwh: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { kv } from '../../../../util/kvSingleton.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
IChatCompleteResult,
|
||||
ICompleteArguments,
|
||||
} from '../../types.js';
|
||||
import { inlineHttpImageUrls } from '../moonshot/imageHandling.js';
|
||||
import {
|
||||
mapNeuralwattApiModel,
|
||||
messagesHaveImageContent,
|
||||
modelSupportsVision,
|
||||
NEURALWATT_DEFAULT_MODEL,
|
||||
NEURALWATT_ID_PREFIX,
|
||||
stripNeuralwattPrefix,
|
||||
type NeuralwattAccountingMethod,
|
||||
type NeuralwattApiModel,
|
||||
type NeuralwattCost,
|
||||
type NeuralwattEnergy,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.neuralwatt.com/v1';
|
||||
const KV_MODELS_KEY = 'neuralwattChat:models';
|
||||
const KV_QUOTA_KEY = 'neuralwattChat:quota';
|
||||
const CACHE_TTL_SEC = 15 * 60;
|
||||
|
||||
type NeuralwattUsage = OpenAI.Completions.CompletionUsage & {
|
||||
request_cost_usd?: number;
|
||||
energy_kwh?: number;
|
||||
energy_joules?: number;
|
||||
measurement_available?: boolean;
|
||||
};
|
||||
|
||||
export class NeuralwattProvider implements IChatProvider {
|
||||
#meteringService: MeteringService;
|
||||
|
||||
#openai: OpenAI;
|
||||
|
||||
#apiKey: string;
|
||||
|
||||
#apiBaseUrl: string = DEFAULT_API_BASE_URL;
|
||||
|
||||
constructor(
|
||||
config: { apiBaseUrl?: string; apiKey: string },
|
||||
meteringService: MeteringService,
|
||||
) {
|
||||
this.#apiBaseUrl = config.apiBaseUrl || DEFAULT_API_BASE_URL;
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: this.#apiBaseUrl,
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return NEURALWATT_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
async list() {
|
||||
const models = await this.models();
|
||||
const modelNames: string[] = [];
|
||||
for (const model of models) {
|
||||
modelNames.push(model.id);
|
||||
if (model.aliases) modelNames.push(...model.aliases);
|
||||
}
|
||||
return modelNames;
|
||||
}
|
||||
|
||||
async models(): Promise<IChatModel[]> {
|
||||
let apiModels = kv.get(KV_MODELS_KEY) as
|
||||
| NeuralwattApiModel[]
|
||||
| undefined;
|
||||
if (!apiModels) {
|
||||
try {
|
||||
const resp = await axios.request({
|
||||
method: 'GET',
|
||||
url: `${this.#apiBaseUrl}/models`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
},
|
||||
});
|
||||
apiModels = resp.data.data ?? [];
|
||||
kv.set(KV_MODELS_KEY, apiModels, { EX: CACHE_TTL_SEC });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Failed to fetch Neuralwatt models:',
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!apiModels) return [];
|
||||
|
||||
const coerced: IChatModel[] = [];
|
||||
for (const model of apiModels) {
|
||||
if (model.metadata?.deprecated) continue;
|
||||
const mapped = mapNeuralwattApiModel(model);
|
||||
if (mapped) coerced.push(mapped);
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached account accounting method (`energy` | `token`) from
|
||||
* `GET /v1/quota`. Used only to annotate returned usage — billing
|
||||
* always prefers `cost.request_cost_usd` on the completion.
|
||||
*/
|
||||
async getAccountingMethod(): Promise<
|
||||
NeuralwattAccountingMethod | undefined
|
||||
> {
|
||||
let method = kv.get(KV_QUOTA_KEY) as
|
||||
| NeuralwattAccountingMethod
|
||||
| undefined;
|
||||
if (method === 'energy' || method === 'token') return method;
|
||||
|
||||
try {
|
||||
const resp = await axios.request({
|
||||
method: 'GET',
|
||||
url: `${this.#apiBaseUrl}/quota`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
},
|
||||
});
|
||||
const raw = resp.data?.balance?.accounting_method;
|
||||
if (raw === 'energy' || raw === 'token') {
|
||||
method = raw;
|
||||
kv.set(KV_QUOTA_KEY, method, { EX: CACHE_TTL_SEC });
|
||||
return method;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Failed to fetch Neuralwatt quota:',
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
reasoning_effort,
|
||||
reasoning,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
// Catalog carries per-model vision / reasoning_effort flags from
|
||||
// Neuralwatt `GET /models` — resolve against it before shaping the
|
||||
// upstream request so image-bearing prompts land on a vision model.
|
||||
const availableModels = await this.models();
|
||||
const hasImages = messagesHaveImageContent(messages ?? []);
|
||||
const modelLower = (model ?? '').toLowerCase();
|
||||
let modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].some(
|
||||
(id) => id.toLowerCase() === modelLower,
|
||||
),
|
||||
) ?? undefined;
|
||||
|
||||
if (!modelUsed) {
|
||||
if (hasImages) {
|
||||
modelUsed = availableModels.find((m) =>
|
||||
modelSupportsVision(m),
|
||||
);
|
||||
}
|
||||
modelUsed =
|
||||
modelUsed ||
|
||||
availableModels.find((m) => m.id === this.getDefaultModel()) ||
|
||||
availableModels[0];
|
||||
}
|
||||
|
||||
if (!modelUsed) {
|
||||
throw new Error('No Neuralwatt models available');
|
||||
}
|
||||
|
||||
if (hasImages && !modelSupportsVision(modelUsed)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Model ${modelUsed.id} does not support image input`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
const modelIdForParams = stripNeuralwattPrefix(modelUsed.id);
|
||||
const actor = Context.get('actor');
|
||||
const accountingMethod = await this.getAccountingMethod();
|
||||
|
||||
// Vision models: Neuralwatt (like Moonshot) expects inline data URLs
|
||||
// rather than remote http(s) fetches for image_url parts.
|
||||
if (modelSupportsVision(modelUsed)) {
|
||||
await inlineHttpImageUrls(messages);
|
||||
}
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const requestedReasoningEffort =
|
||||
reasoning_effort ?? reasoning?.effort;
|
||||
const supportsReasoningEffort = modelUsed.reasoning_effort === true;
|
||||
|
||||
const completionParams = {
|
||||
messages,
|
||||
model: modelIdForParams,
|
||||
...(tools ? { tools } : {}),
|
||||
...(max_tokens !== undefined ? { max_tokens } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(supportsReasoningEffort && requestedReasoningEffort
|
||||
? { reasoning_effort: requestedReasoningEffort }
|
||||
: {}),
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
} as ChatCompletionCreateParams;
|
||||
|
||||
const completion =
|
||||
await this.#openai.chat.completions.create(completionParams);
|
||||
|
||||
const usage_calculator = ({
|
||||
usage,
|
||||
cost,
|
||||
energy,
|
||||
}: {
|
||||
usage: NeuralwattUsage;
|
||||
cost?: NeuralwattCost;
|
||||
energy?: NeuralwattEnergy;
|
||||
}) => {
|
||||
// Non-streaming spreads the full completion into this call;
|
||||
// streaming merges top-level cost/energy onto `usage` via the
|
||||
// index_usage_from_stream_chunk deviation below.
|
||||
const requestCostUsd =
|
||||
typeof cost?.request_cost_usd === 'number'
|
||||
? cost.request_cost_usd
|
||||
: typeof usage.request_cost_usd === 'number'
|
||||
? usage.request_cost_usd
|
||||
: undefined;
|
||||
|
||||
const energyBlock = energy ?? {
|
||||
energy_kwh: usage.energy_kwh,
|
||||
energy_joules: usage.energy_joules,
|
||||
measurement_available: usage.measurement_available,
|
||||
};
|
||||
|
||||
const trackedTokens = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const energyUnits: Record<string, number> = {};
|
||||
if (
|
||||
energyBlock.measurement_available !== false &&
|
||||
typeof energyBlock.energy_kwh === 'number' &&
|
||||
Number.isFinite(energyBlock.energy_kwh) &&
|
||||
energyBlock.energy_kwh > 0
|
||||
) {
|
||||
energyUnits.energy_kwh = energyBlock.energy_kwh;
|
||||
}
|
||||
if (
|
||||
energyBlock.measurement_available !== false &&
|
||||
typeof energyBlock.energy_joules === 'number' &&
|
||||
Number.isFinite(energyBlock.energy_joules) &&
|
||||
energyBlock.energy_joules > 0
|
||||
) {
|
||||
energyUnits.energy_joules = energyBlock.energy_joules;
|
||||
}
|
||||
|
||||
const annotate = (tracked: Record<string, number>) => {
|
||||
const out: Record<string, number | string> = { ...tracked };
|
||||
if (accountingMethod) {
|
||||
out.accounting_method = accountingMethod;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof requestCostUsd === 'number' &&
|
||||
Number.isFinite(requestCostUsd)
|
||||
) {
|
||||
const billedTrackedUsage = {
|
||||
...trackedTokens,
|
||||
...energyUnits,
|
||||
billedUsage: 1,
|
||||
};
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
|
||||
);
|
||||
costOverwrites.billedUsage = requestCostUsd * 100_000_000;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
billedTrackedUsage,
|
||||
actor!,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
const result = annotate(billedTrackedUsage);
|
||||
result.usd_cents = requestCostUsd * 100;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: catalog token rates (preflight / when Neuralwatt
|
||||
// omits request_cost_usd).
|
||||
const trackedUsage = { ...trackedTokens, ...energyUnits };
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
if (k === 'energy_kwh' || k === 'energy_joules') {
|
||||
return [k, 0];
|
||||
}
|
||||
return [k, (modelUsed.costs[k] ?? 0) * v];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
return annotate(trackedUsage);
|
||||
};
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
deviations: {
|
||||
index_usage_from_stream_chunk: (chunk: {
|
||||
usage?: NeuralwattUsage;
|
||||
cost?: NeuralwattCost;
|
||||
energy?: NeuralwattEnergy;
|
||||
}) => {
|
||||
if (!chunk.usage) return chunk.usage;
|
||||
return {
|
||||
...chunk.usage,
|
||||
...(typeof chunk.cost?.request_cost_usd === 'number'
|
||||
? {
|
||||
request_cost_usd:
|
||||
chunk.cost.request_cost_usd,
|
||||
}
|
||||
: {}),
|
||||
...(chunk.energy
|
||||
? {
|
||||
energy_kwh: chunk.energy.energy_kwh,
|
||||
energy_joules: chunk.energy.energy_joules,
|
||||
measurement_available:
|
||||
chunk.energy.measurement_available,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
usage_calculator,
|
||||
stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
checkModeration(
|
||||
_text: string,
|
||||
): ReturnType<IChatProvider['checkModeration']> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export { NEURALWATT_ID_PREFIX, NEURALWATT_DEFAULT_MODEL };
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { usdPerMToken } from '../../utils/pricing.js';
|
||||
|
||||
export const NEURALWATT_ID_PREFIX = 'neuralwatt:';
|
||||
export const NEURALWATT_DEFAULT_MODEL = 'neuralwatt:deepseek-v4-flash';
|
||||
|
||||
/**
|
||||
* Shape of one entry in Neuralwatt's `GET /v1/models` catalog.
|
||||
* Pricing is USD per million tokens under `metadata.pricing` and is used
|
||||
* only for Puter preflight estimates / token-cost fallback. Live billing
|
||||
* uses each completion's `cost.request_cost_usd`.
|
||||
*/
|
||||
|
||||
export type NeuralwattApiModel = {
|
||||
id: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
owned_by?: string;
|
||||
max_model_len?: number;
|
||||
metadata?: {
|
||||
display_name?: string;
|
||||
description?: string | null;
|
||||
provider?: string;
|
||||
huggingface_id?: string | null;
|
||||
pricing?: {
|
||||
input_per_million?: number;
|
||||
output_per_million?: number;
|
||||
cached_input_per_million?: number | null;
|
||||
cached_output_per_million?: number | null;
|
||||
currency?: string;
|
||||
pricing_tbd?: boolean;
|
||||
};
|
||||
capabilities?: {
|
||||
tools?: boolean;
|
||||
json_mode?: boolean;
|
||||
vision?: boolean;
|
||||
reasoning?: boolean;
|
||||
reasoning_effort?: boolean;
|
||||
streaming?: boolean;
|
||||
system_role?: boolean;
|
||||
developer_role?: boolean;
|
||||
};
|
||||
limits?: {
|
||||
max_context_length?: number | null;
|
||||
max_output_tokens?: number | null;
|
||||
max_images?: number | null;
|
||||
};
|
||||
deprecated?: boolean;
|
||||
deprecated_message?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type NeuralwattAccountingMethod = 'energy' | 'token';
|
||||
|
||||
export type NeuralwattEnergy = {
|
||||
energy_joules?: number;
|
||||
energy_kwh?: number;
|
||||
measurement_available?: boolean;
|
||||
avg_power_watts?: number;
|
||||
duration_seconds?: number;
|
||||
attribution_method?: string;
|
||||
attribution_ratio?: number;
|
||||
};
|
||||
|
||||
export type NeuralwattCost = {
|
||||
request_cost_usd?: number;
|
||||
cache_savings_usd?: number;
|
||||
allowance_remaining_usd?: number;
|
||||
};
|
||||
|
||||
/** Strip the Puter-facing `neuralwatt:` prefix for upstream API calls. */
|
||||
export const stripNeuralwattPrefix = (modelId: string): string =>
|
||||
modelId.startsWith(NEURALWATT_ID_PREFIX)
|
||||
? modelId.slice(NEURALWATT_ID_PREFIX.length)
|
||||
: modelId;
|
||||
|
||||
/**
|
||||
* Map a Neuralwatt catalog entry to Puter's `IChatModel`. Returns `null`
|
||||
* when pricing is TBD (placeholders) so the model is not offered for
|
||||
* preflight credit checks until Neuralwatt publishes real rates.
|
||||
*/
|
||||
export const mapNeuralwattApiModel = (
|
||||
model: NeuralwattApiModel,
|
||||
): IChatModel | null => {
|
||||
const pricing = model.metadata?.pricing;
|
||||
if (pricing?.pricing_tbd) return null;
|
||||
|
||||
const inputUsd = Number(pricing?.input_per_million ?? 0);
|
||||
const outputUsd = Number(pricing?.output_per_million ?? 0);
|
||||
const cachedUsd =
|
||||
pricing?.cached_input_per_million == null
|
||||
? 0
|
||||
: Number(pricing.cached_input_per_million);
|
||||
|
||||
const caps = model.metadata?.capabilities;
|
||||
const limits = model.metadata?.limits;
|
||||
const context =
|
||||
limits?.max_context_length ?? model.max_model_len ?? undefined;
|
||||
const maxTokens = limits?.max_output_tokens ?? context ?? 0;
|
||||
|
||||
const inputModalities = ['text'];
|
||||
if (caps?.vision) inputModalities.push('image');
|
||||
|
||||
const displayName = model.metadata?.display_name || model.id;
|
||||
const pathTail = model.id.includes('/')
|
||||
? model.id.split('/').slice(1).join('/')
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: `${NEURALWATT_ID_PREFIX}${model.id}`,
|
||||
name: `${displayName} (Neuralwatt)`,
|
||||
aliases: [
|
||||
model.id,
|
||||
`neuralwatt/${model.id}`,
|
||||
...(pathTail && pathTail !== model.id ? [pathTail] : []),
|
||||
],
|
||||
context,
|
||||
max_tokens: maxTokens,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: usdPerMToken(inputUsd, outputUsd, cachedUsd),
|
||||
modalities: { input: inputModalities, output: ['text'] },
|
||||
tool_call: caps?.tools === true,
|
||||
// Surfaced from Neuralwatt `/models` so `complete()` can gate
|
||||
// reasoning_effort without re-fetching the catalog entry.
|
||||
reasoning_effort: caps?.reasoning_effort === true,
|
||||
...(typeof limits?.max_images === 'number'
|
||||
? { max_images: limits.max_images }
|
||||
: {}),
|
||||
...(model.metadata?.description
|
||||
? { description: model.metadata.description }
|
||||
: {}),
|
||||
...(model.created
|
||||
? {
|
||||
release_date: new Date(model.created * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** True when the Puter model catalog entry advertises image input. */
|
||||
export const modelSupportsVision = (model: IChatModel): boolean =>
|
||||
Array.isArray(model.modalities?.input) &&
|
||||
model.modalities.input.includes('image');
|
||||
|
||||
/**
|
||||
* Detect image / puter_path parts so we can prefer a vision-capable model
|
||||
* from the Neuralwatt catalog (or reject a text-only pick).
|
||||
*/
|
||||
export const messagesHaveImageContent = (
|
||||
messages: Array<{ content?: unknown }>,
|
||||
): boolean => {
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const part of message.content as Array<Record<string, unknown>>) {
|
||||
if (!part || typeof part !== 'object') continue;
|
||||
if (part.type === 'image_url' || part.image_url) return true;
|
||||
if (typeof part.puter_path === 'string' && part.puter_path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
Reference in New Issue
Block a user