mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 15:37:12 +00:00
feat: add Infron AI chat provider (#3433)
Adds Infron (https://infron.ai) as an aggregator provider for the puter-chat-completion driver, following the OpenRouter provider as reference per doc/contributing-apis.md. - OpenAI-compatible gateway at llm.onerouter.pro/v1 - Dynamic model catalog (kv-cached 15 min, authenticated fetch), filtered to chat-capable LLM entries - Bills the gateway-reported authoritative cost when present (usage: {include: true}); falls back to catalog per-token pricing - Registered as an aggregator so its aliases never shadow first-party providers - Offline unit tests (mocked SDK/axios against a real test server) plus an env-gated integration test
This commit is contained in:
@@ -287,6 +287,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"infron": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://llm.onerouter.pro/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
|
||||
@@ -38,6 +38,7 @@ import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
|
||||
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
|
||||
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';
|
||||
@@ -987,6 +988,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const infron = providers['infron'];
|
||||
const infronKey = readKey(infron);
|
||||
if (infronKey) {
|
||||
this.#providers['infron'] = new InfronProvider(
|
||||
{
|
||||
apiKey: infronKey,
|
||||
apiBaseUrl: infron?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
}
|
||||
|
||||
// Fake provider — always available for testing
|
||||
this.#providers['fake-chat'] = new FakeChatProvider();
|
||||
}
|
||||
@@ -994,7 +1007,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// -- Model map ---------------------------------------------------
|
||||
|
||||
async #buildModelMap() {
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter']);
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter', 'infron']);
|
||||
|
||||
for (const providerName in this.#providers) {
|
||||
const provider = this.#providers[providerName];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Infron aggregator.
|
||||
*
|
||||
* Routes through Infron to a tiny upstream model
|
||||
* (`deepseek/deepseek-v4-flash`). Skipped when
|
||||
* `PUTER_TEST_AI_INFRON_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { InfronProvider } from './InfronProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_INFRON_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))('InfronProvider (integration)', () => {
|
||||
it('returns a non-empty completion via Infron', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new InfronProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'Say hi in one word.' }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } }).message
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* 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 InfronProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs InfronProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. Infron is OpenAI-compatible, so the OpenAI SDK is mocked at
|
||||
* the module boundary; the model catalog is fetched via `axios`
|
||||
* which is mocked at its module boundary too. Both are the real
|
||||
* network egress points. Each test clears the kv-cached model list.
|
||||
* The companion integration test (InfronProvider.integration.test.ts)
|
||||
* exercises the real Infron endpoint.
|
||||
*/
|
||||
|
||||
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 { InfronProvider } from './InfronProvider.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 (model catalog endpoint) ─────────────────────────────
|
||||
|
||||
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_KEY = 'infronChat:models';
|
||||
|
||||
// Prices are USD per million tokens (Infron catalog convention).
|
||||
const SAMPLE_API_MODELS = [
|
||||
{
|
||||
id: 'deepseek/deepseek-v4-flash',
|
||||
display_name: 'DeepSeek: DeepSeek V4 Flash',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 384000,
|
||||
min_prompt_price: 10,
|
||||
min_completion_price: 30,
|
||||
},
|
||||
{
|
||||
id: 'anthropic/claude-haiku-4.5',
|
||||
display_name: 'Anthropic: Claude Haiku 4.5',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 200000,
|
||||
max_output_tokens: 8192,
|
||||
min_prompt_price: 2,
|
||||
min_completion_price: 10,
|
||||
},
|
||||
{
|
||||
// Non-chat modality — filtered out.
|
||||
id: 'black-forest-labs/flux-2.1',
|
||||
display_name: 'FLUX 2.1',
|
||||
category_type: 'Text to Image',
|
||||
supported_endpoint_types: ['openai'],
|
||||
min_request_price: 0.04,
|
||||
},
|
||||
{
|
||||
// Display-only entries are not callable — filtered out.
|
||||
id: 'example/display-only-model',
|
||||
display_name: 'Display Only',
|
||||
category_type: 'LLM',
|
||||
is_display_only: true,
|
||||
supported_endpoint_types: ['openai'],
|
||||
},
|
||||
];
|
||||
|
||||
const seedModelsCache = () =>
|
||||
axiosRequestMock.mockResolvedValue({ data: { data: SAMPLE_API_MODELS } });
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = () => {
|
||||
const provider = new InfronProvider(
|
||||
{ apiKey: 'test-key' },
|
||||
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();
|
||||
seedModelsCache();
|
||||
kv.del(KV_KEY);
|
||||
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
kv.del(KV_KEY);
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider construction', () => {
|
||||
it('points the OpenAI SDK at the Infron base URL with the configured key', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledTimes(1);
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://llm.onerouter.pro/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an apiBaseUrl override', () => {
|
||||
new InfronProvider(
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
apiBaseUrl: 'https://custom.infron.example/v1',
|
||||
},
|
||||
server.services.metering,
|
||||
);
|
||||
expect(openAICtor).toHaveBeenLastCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://custom.infron.example/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider model catalog', () => {
|
||||
it('returns the infron-prefixed default model id', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe(
|
||||
'infron:deepseek/deepseek-v4-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the API key as a bearer token on the catalog fetch', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
const [args] = axiosRequestMock.mock.calls[0]!;
|
||||
expect(args.url).toBe('https://llm.onerouter.pro/v1/models');
|
||||
expect(args.headers).toMatchObject({
|
||||
Authorization: 'Bearer test-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('list() prefixes ids with infron: and filters non-chat and display-only entries', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
expect(ids).toContain('infron:deepseek/deepseek-v4-flash');
|
||||
expect(ids).toContain('infron:anthropic/claude-haiku-4.5');
|
||||
expect(ids).not.toContain('infron:black-forest-labs/flux-2.1');
|
||||
expect(ids).not.toContain('infron:example/display-only-model');
|
||||
});
|
||||
|
||||
it('caches the model list in kv after the first axios round-trip', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
await provider.models();
|
||||
// Second call should be a cache hit, not a second axios request.
|
||||
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts USD-per-million-token prices to microcents per token', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
// $10/M tokens → 10 * 100 = 1000 microcents per token.
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'infron:deepseek/deepseek-v4-flash',
|
||||
costs: expect.objectContaining({
|
||||
tokens: 1_000_000,
|
||||
prompt: 1000,
|
||||
completion: 3000,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ──────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.complete request shape', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
it('strips the infron: prefix from the wire model id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
// infron: prefix is dropped before the SDK call.
|
||||
expect(args.model).toBe('deepseek/deepseek-v4-flash');
|
||||
// Infron requires `usage: { include: true }` to surface the
|
||||
// cost field — the provider always sets this.
|
||||
expect(args.usage).toEqual({ include: true });
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/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: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[1]![0].stream_options).toEqual({
|
||||
include_usage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion: cost calculator branches ─────────────────
|
||||
|
||||
describe('InfronProvider.complete non-stream output', () => {
|
||||
it('uses the cost-bearing branch when the top-level cost is present', 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 },
|
||||
},
|
||||
// Infron reports cost at the top level of the completion,
|
||||
// not inside `usage`.
|
||||
cost: 0.0001,
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
)) as { usage: Record<string, number> };
|
||||
|
||||
// The cost-bearing branch zeroes per-token costs and bills via a
|
||||
// single `billedUsage` line item priced at cost * 1e8.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
|
||||
expect(usage).toMatchObject({
|
||||
prompt: 100 - 10, // prompt_tokens - cached
|
||||
completion: 50,
|
||||
input_cache_read: 10,
|
||||
billedUsage: 1,
|
||||
});
|
||||
// All per-token costs are zeroed so Infron's authoritative
|
||||
// cost is the only thing that bills.
|
||||
expect(overrides.prompt).toBe(0);
|
||||
expect(overrides.completion).toBe(0);
|
||||
expect(overrides.input_cache_read).toBe(0);
|
||||
expect(overrides.billedUsage).toBe(0.0001 * 100_000_000);
|
||||
// The returned usage exposes usd_cents derived from cost.
|
||||
expect(result.usage.usd_cents).toBe(0.0001 * 100);
|
||||
});
|
||||
|
||||
it('falls back to per-token pricing when cost is absent', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
// No top-level `cost` → fallback branch.
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
// deepseek-v4-flash catalog pricing converted to microcents per
|
||||
// token: prompt=$10/M → 1000, completion=$30/M → 3000; cache
|
||||
// reads fall back to the full prompt rate.
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toMatchObject({
|
||||
prompt: 90,
|
||||
completion: 50,
|
||||
input_cache_read: 10,
|
||||
});
|
||||
expect(overrides.prompt).toBe(90 * 1000);
|
||||
expect(overrides.completion).toBe(50 * 3000);
|
||||
expect(overrides.input_cache_read).toBe(10 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.complete streaming', () => {
|
||||
it('streams text deltas through to text events and meters the final-chunk cost', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'hel' } }] },
|
||||
{ choices: [{ delta: { content: 'lo' } }] },
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
},
|
||||
// Cost rides at the top level of the final chunk.
|
||||
cost: 0.00005,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
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<void>;
|
||||
}
|
||||
).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']);
|
||||
|
||||
// Cost-branch metering on the final chunk.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
|
||||
expect(overrides.billedUsage).toBe(0.00005 * 100_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.checkModeration', () => {
|
||||
it('throws — Infron provider does not implement moderation', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(() => provider.checkModeration('anything')).toThrow(
|
||||
/not implemented/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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 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';
|
||||
|
||||
/**
|
||||
* Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter
|
||||
* there is no `pricing` object; prices are USD per million tokens in
|
||||
* `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat
|
||||
* modalities (image, video, embeddings) that this provider filters out via
|
||||
* `category_type`.
|
||||
*/
|
||||
type InfronApiModel = {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
category_type?: string;
|
||||
is_display_only?: boolean;
|
||||
supported_endpoint_types?: string[];
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
min_prompt_price?: number;
|
||||
min_completion_price?: number;
|
||||
min_request_price?: number;
|
||||
};
|
||||
|
||||
type InfronUsage = OpenAI.Completions.CompletionUsage & {
|
||||
cost?: number;
|
||||
};
|
||||
|
||||
const KV_MODELS_KEY = 'infronChat:models';
|
||||
|
||||
export class InfronProvider implements IChatProvider {
|
||||
#meteringService: MeteringService;
|
||||
|
||||
#openai: OpenAI;
|
||||
|
||||
#apiKey: string;
|
||||
|
||||
#apiBaseUrl: string = 'https://llm.onerouter.pro/v1';
|
||||
|
||||
constructor(
|
||||
config: { apiBaseUrl?: string; apiKey: string },
|
||||
meteringService: MeteringService,
|
||||
) {
|
||||
this.#apiBaseUrl = config.apiBaseUrl || 'https://llm.onerouter.pro/v1';
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: this.#apiBaseUrl,
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'infron:deepseek/deepseek-v4-flash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of available model names
|
||||
*
|
||||
* @returns {Promise<string[]>} Array of model identifiers
|
||||
*/
|
||||
async list() {
|
||||
const models = await this.models();
|
||||
const model_names: string[] = [];
|
||||
for (const model of models) {
|
||||
model_names.push(model.id);
|
||||
}
|
||||
return model_names;
|
||||
}
|
||||
|
||||
/** AI Chat completion method. See AIChatService for more details. */
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
const availableModels = await this.models();
|
||||
const modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
const modelIdForParams = modelUsed.id.startsWith('infron:')
|
||||
? modelUsed.id.slice('infron:'.length)
|
||||
: modelUsed.id;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const completionParams = {
|
||||
messages,
|
||||
model: modelIdForParams,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens,
|
||||
temperature,
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
// Surfaces the authoritative `cost` field (USD) on the
|
||||
// response so metering doesn't depend on catalog prices.
|
||||
usage: { include: true },
|
||||
} as ChatCompletionCreateParams;
|
||||
|
||||
const completion =
|
||||
await this.#openai.chat.completions.create(completionParams);
|
||||
|
||||
const usage_calculator = ({
|
||||
usage,
|
||||
cost,
|
||||
}: {
|
||||
usage: InfronUsage;
|
||||
cost?: number;
|
||||
}) => {
|
||||
// Infron reports `cost` at the top level of the response, not
|
||||
// inside `usage`. Non-streaming calls get it via the spread
|
||||
// completion below; streaming injects it into `usage` via the
|
||||
// `index_usage_from_stream_chunk` deviation.
|
||||
const authoritativeCost =
|
||||
typeof cost === 'number' ? cost : usage.cost;
|
||||
const trackedUsage = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion: usage.completion_tokens ?? 0,
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request: 1,
|
||||
};
|
||||
if (typeof authoritativeCost === 'number') {
|
||||
// Bill the gateway-reported cost as a single line item and
|
||||
// zero the per-token costs so nothing double-bills.
|
||||
const billedTrackedUsage = { ...trackedUsage, billedUsage: 1 };
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
|
||||
);
|
||||
costOverwrites.billedUsage =
|
||||
authoritativeCost * 100_000_000 || 1;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
billedTrackedUsage,
|
||||
actor,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
(billedTrackedUsage as Record<string, number>).usd_cents =
|
||||
authoritativeCost * 100;
|
||||
return billedTrackedUsage;
|
||||
}
|
||||
// Fallback: per-token pricing from the model catalog.
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [
|
||||
k,
|
||||
(modelUsed.costs[k] ?? 0) *
|
||||
trackedUsage[k as keyof typeof trackedUsage],
|
||||
];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
return trackedUsage;
|
||||
};
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
deviations: {
|
||||
index_usage_from_stream_chunk: (chunk: {
|
||||
usage?: InfronUsage;
|
||||
cost?: number;
|
||||
}) =>
|
||||
chunk.usage
|
||||
? { ...chunk.usage, cost: chunk.cost }
|
||||
: chunk.usage,
|
||||
},
|
||||
usage_calculator,
|
||||
stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
async models() {
|
||||
let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined;
|
||||
if (!models) {
|
||||
try {
|
||||
const resp = await axios.request({
|
||||
method: 'GET',
|
||||
url: `${this.#apiBaseUrl}/models`,
|
||||
// Infron requires authentication on the catalog endpoint.
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
models = resp.data.data;
|
||||
kv.set(KV_MODELS_KEY, models, { EX: 15 * 60 }); // cache for 15 minutes
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
if (!models) return [];
|
||||
const coerced_models: IChatModel[] = [];
|
||||
for (const model of models) {
|
||||
// The catalog mixes chat with image/video/embedding/search
|
||||
// models — only chat-completion-capable models belong here.
|
||||
if (model.category_type !== 'LLM') continue;
|
||||
if (model.is_display_only) continue;
|
||||
if (!(model.supported_endpoint_types ?? []).includes('openai')) {
|
||||
continue;
|
||||
}
|
||||
// Catalog prices are USD per million tokens; costs are
|
||||
// microcents per token, so the conversion is ×100.
|
||||
const promptCost = Math.round((model.min_prompt_price ?? 0) * 100);
|
||||
coerced_models.push({
|
||||
id: `infron:${model.id}`,
|
||||
name: `${model.display_name || model.id} (Infron)`,
|
||||
aliases: [
|
||||
model.id,
|
||||
...(model.display_name ? [model.display_name] : []),
|
||||
`infron/${model.id}`,
|
||||
model.id.split('/').slice(1).join('/'),
|
||||
],
|
||||
context: model.context_length,
|
||||
max_tokens: model.max_output_tokens ?? 0,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt',
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt: promptCost,
|
||||
completion: Math.round(
|
||||
(model.min_completion_price ?? 0) * 100,
|
||||
),
|
||||
// The catalog carries no cache-read price; charge the
|
||||
// full prompt rate in the fallback path so cached
|
||||
// tokens are never billed below list. The normal path
|
||||
// bills the gateway-reported cost instead.
|
||||
input_cache_read: promptCost,
|
||||
// USD per request → microcents per request.
|
||||
request: Math.round(
|
||||
(model.min_request_price ?? 0) * 1_000_000 * 100,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
return coerced_models;
|
||||
}
|
||||
|
||||
checkModeration(
|
||||
_text: string,
|
||||
): ReturnType<IChatProvider['checkModeration']> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user