refactor(ai-chat): drop provider surface and OpenAIUtil code nothing uses

- list() leaves IChatProvider and every provider that isn't Azure or xAI
  (both wait on the open fix for those files); only tests called it.
- OpenAI Chat, OpenAI Responses and Meta no longer inline puter_path parts:
  the driver does it before every attempt, so theirs found nothing to do. The
  stores and FS service they took for it go too.
- providers/ChatProvider.ts had no subclasses.
- OpenAIUtil: one hoistToolUseBlocks for the Chat and Responses message
  passes (Responses keeps canonical_id), and the dead create_usage_calculator,
  unused Responses deviations and constant mode/toolblock are gone.

checkModeration stays on IChatProvider: the open Azure moderation fix calls it
through that interface.
This commit is contained in:
Daniel Salazar committed 2026-10-10 20:01:50 -04:00
1 parent ddff917630
commit c6352cf71f
34 files changed
+108 -384

No files matched your search

@@ -1626,23 +1626,13 @@ export class ChatCompletionDriver extends PuterDriver {
const openaiKey = readKey(providers['openai-completion']);
if (openaiKey) {
const openaiStores = {
fsEntry: this.stores.fsEntry,
s3Object: this.stores.s3Object,
};
const openaiCompletions = new OpenAiChatProvider(
{ apiKey: openaiKey },
metering,
openaiStores,
this.services.fs,
{
apiKey: openaiKey,
},
);
const openaiResponses = new OpenAiResponsesChatProvider(
metering,
openaiStores,
this.services.fs,
{ apiKey: openaiKey },
metering,
);
// web_search is Responses-only; let the Completions path delegate
// to its sibling when users request it.
@@ -1662,16 +1652,11 @@ export class ChatCompletionDriver extends PuterDriver {
const metaKey = readKey(meta);
if (metaKey) {
this.#providers['meta'] = new MetaProvider(
metering,
{
fsEntry: this.stores.fsEntry,
s3Object: this.stores.s3Object,
},
this.services.fs,
{
apiKey: metaKey,
apiBaseUrl: meta?.apiBaseUrl as string | undefined,
},
metering,
);
}
@@ -1,44 +0,0 @@
/*
* 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,
IChatProvider,
ICompleteArguments,
IChatCompleteResult,
} from '../types.js';
/**
* Abstract base for AI chat providers. Each provider wraps a single upstream
* API (Anthropic, OpenAI, …) and exposes the unified `IChatProvider` contract.
*/
export class ChatProvider implements IChatProvider {
getDefaultModel(): string {
return '';
}
models(): IChatModel[] | Promise<IChatModel[]> {
return [];
}
list(): string[] | Promise<string[]> {
return [];
}
async complete(_arg: ICompleteArguments): Promise<IChatCompleteResult> {
throw new Error('Method not implemented.');
}
}
@@ -74,9 +74,7 @@ export class FakeChatProvider implements IChatProvider {
},
];
}
async list() {
return ['fake', 'costly', 'abuse'];
}
async complete({
messages,
stream,
@@ -40,7 +40,7 @@ import {
meterChatUsage,
type MeterChatUsageOptions,
} from '../utils/meterChatUsage.js';
import { modelLookupNames, normalizeModelKey } from '../utils/modelRouting.js';
import { normalizeModelKey } from '../utils/modelRouting.js';
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
import {
openAICompatParams,
@@ -140,17 +140,6 @@ export class OpenAICompatProvider implements IChatProvider {
return this.#options.models?.() ?? [];
}
list(): string[] | Promise<string[]> {
const models = this.models();
return Array.isArray(models)
? modelLookupNames(models)
: this.#listAsync(models);
}
async #listAsync(models: Promise<IChatModel[]>) {
return modelLookupNames(await models);
}
/** The model key this provider records usage under. */
meteringModelKey(modelId: string): string {
const prefix = this.#options.meteringPrefix;
@@ -50,6 +50,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { ALIBABA_MODELS } from './models.js';
import { AlibabaProvider } from './AlibabaProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -170,7 +171,7 @@ describe('AlibabaProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of ALIBABA_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -209,7 +210,7 @@ describe('AlibabaProvider model catalog', () => {
'deepseek-v4-flash',
])('drops %s, which Model Studio is retiring', async (id) => {
const { provider } = makeProvider();
expect(await provider.list()).not.toContain(id);
expect(modelLookupNames(await provider.models())).not.toContain(id);
});
});
@@ -51,6 +51,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { BytePlusProvider } from './BytePlusProvider.js';
import { BYTEPLUS_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// -- OpenAI SDK mock -------------------------------------------------
//
@@ -190,7 +191,7 @@ describe('BytePlusProvider model catalog', () => {
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const names = provider.list();
const names = modelLookupNames(provider.models());
for (const m of BYTEPLUS_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -228,12 +229,12 @@ describe('BytePlusProvider model catalog', () => {
'gpt-oss-120b-250805',
])('drops %s, which ModelArk has deprecated', (id) => {
const { provider } = makeProvider();
expect(provider.list()).not.toContain(id);
expect(modelLookupNames(provider.models())).not.toContain(id);
});
it('never claims the bare deepseek-v4 names owned by the DeepSeek provider', () => {
const { provider } = makeProvider();
const names = provider.list();
const names = modelLookupNames(provider.models());
expect(names).not.toContain('deepseek-v4-pro');
expect(names).not.toContain('deepseek-v4-flash');
expect(names).toContain('byteplus/deepseek-v4-pro');
@@ -58,6 +58,7 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { FILES_API_BETA } from './fileUpload.js';
import { CLAUDE_MODELS } from './models.js';
import { ClaudeProvider } from './ClaudeProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── Anthropic SDK mock ──────────────────────────────────────────────
@@ -281,7 +282,7 @@ describe('ClaudeProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of CLAUDE_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -302,7 +303,7 @@ describe('ClaudeProvider model catalog', () => {
it('drops the deprecated claude-sonnet-4-5 and its aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).not.toContain('claude-sonnet-4-5-20250929');
expect(ids).not.toContain('claude-sonnet-4-5');
expect(ids).not.toContain('claude-sonnet-4.5');
@@ -1357,7 +1358,7 @@ describe('ClaudeProvider.complete non-stream output', () => {
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(await provider.list()).toContain(model);
expect(modelLookupNames(await provider.models())).toContain(model);
const [args] = messagesCreateMock.mock.calls[0]!;
expect(args.model).toBe(canonicalId);
expect(args.max_tokens).toBe(128_000);
@@ -48,7 +48,6 @@ import type {
AIChatTextStream,
AIChatToolUseStream,
} from '../../utils/Streaming.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import {
allowlistedFromHeader,
applySafeguardsPolicy,
@@ -294,10 +293,6 @@ export class ClaudeProvider implements IChatProvider {
return CLAUDE_MODELS;
}
async list() {
return modelLookupNames(this.models());
}
/** The model key this provider records usage under. */
meteringModelKey(modelId: string): string {
return `claude:${modelId}`;
@@ -50,6 +50,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { DEEPSEEK_MODELS } from './models.js';
import { DeepSeekProvider } from './DeepSeekProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -159,7 +160,7 @@ describe('DeepSeekProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of DEEPSEEK_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -489,7 +490,7 @@ describe('DeepSeekProvider model resolution', () => {
'deepseek:deepseek/deepseek-reasoner',
])('drops the discontinued %s instead of redirecting it', async (alias) => {
const { provider } = makeProvider();
expect(await provider.list()).not.toContain(alias);
expect(modelLookupNames(await provider.models())).not.toContain(alias);
});
});
@@ -51,6 +51,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { GEMINI_MODELS } from './models.js';
import { GeminiChatProvider } from './GeminiChatProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
//
@@ -172,7 +173,7 @@ describe('GeminiChatProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of GEMINI_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -192,7 +193,7 @@ describe('GeminiChatProvider model catalog', () => {
// flattening the catalog itself.
it('list() emits every id exactly once', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).toHaveLength(new Set(ids).size);
});
});
@@ -413,7 +414,7 @@ describe('GeminiChatProvider model resolution', () => {
'gemini-3-flash-preview',
])('drops the deprecated %s instead of redirecting it', async (model) => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).not.toContain(model);
expect(ids).not.toContain(`google/${model}`);
});
@@ -51,6 +51,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { GROQ_MODELS } from './models.js';
import { GroqAIProvider } from './GroqAIProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── Groq SDK mock ───────────────────────────────────────────────────
@@ -163,7 +164,7 @@ describe('GroqAIProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of GROQ_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -195,7 +196,7 @@ describe('GroqAIProvider model catalog', () => {
'meta-llama/llama-guard-4-12b',
])('drops the deprecated %s', async (id) => {
const { provider } = makeProvider();
expect(await provider.list()).not.toContain(id);
expect(modelLookupNames(await provider.models())).not.toContain(id);
});
});
@@ -52,6 +52,7 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { usdPerMToken } from '../../utils/pricing.js';
import { HOONIFY_MODELS } from './models.js';
import { HoonifyProvider } from './HoonifyProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
//
@@ -191,7 +192,7 @@ describe('HoonifyProvider model catalog', () => {
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const names = provider.list();
const names = modelLookupNames(provider.models());
for (const m of HOONIFY_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -216,7 +217,7 @@ describe('HoonifyProvider model catalog', () => {
// `AGGREGATOR_PROVIDERS` ranks Hoonify behind the vendor so it only
// serves once Alibaba's route fails.
const { provider } = makeProvider();
expect(provider.list()).toContain('qwen/qwen3.6-27b');
expect(modelLookupNames(provider.models())).toContain('qwen/qwen3.6-27b');
});
it('exposes Inkling Small at its list pricing and sends its exact-case wire id', async () => {
@@ -50,6 +50,7 @@ import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { InfronProvider } from './InfronProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -331,7 +332,7 @@ describe('InfronProvider model catalog', () => {
it('list() prefixes ids with infron: and filters non-chat, display-only, and deprecated entries', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).toContain('infron:deepseek/deepseek-v4-flash');
expect(ids).toContain(provider.getDefaultModel());
expect(ids).toContain('infron:anthropic/claude-haiku-4.5');
@@ -417,7 +418,7 @@ describe('InfronProvider model catalog', () => {
it('still offers an explicit tier id when no default tier is sold', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
// The plain id leaves routing to Infron; the :flex id pins the one
// tier on offer. Same price, different routing guarantee.
expect(ids).toContain('infron:example/flex-only-model');
@@ -27,9 +27,6 @@
*/
import { describe, expect, it } from 'vitest';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
@@ -41,21 +38,14 @@ import { MetaProvider } from './MetaProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_META_API_KEY';
// Only `puter_path` content parts reach the filesystem, and this test sends
// plain text, so the stores stay untouched.
const UNUSED_STORES = {} as { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
const UNUSED_FS_SERVICE = {} as FSService;
describe.skipIf(skipUnlessEnv(ENV_VAR))('MetaProvider (integration)', () => {
it(
'returns a non-empty completion from muse-spark-1.2',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = new MetaProvider(
makeMeteringStub(),
UNUSED_STORES,
UNUSED_FS_SERVICE,
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const result = await withTestActor(() =>
@@ -52,6 +52,7 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { usdPerMToken } from '../../utils/pricing.js';
import { MetaProvider } from './MetaProvider.js';
import { META_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// -- OpenAI SDK mock ----------------------------------------------
//
@@ -93,16 +94,11 @@ afterAll(async () => {
const makeProvider = (config: { apiKey?: string; apiBaseUrl?: string } = {}) =>
new MetaProvider(
server.services.metering,
{
fsEntry: server.stores.fsEntry,
s3Object: server.stores.s3Object,
},
server.services.fs,
{
apiKey: config.apiKey ?? 'test-key',
...(config.apiBaseUrl ? { apiBaseUrl: config.apiBaseUrl } : {}),
},
server.services.metering,
);
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
@@ -207,7 +203,7 @@ describe('MetaProvider model catalog', () => {
});
it('list() flattens canonical ids and aliases', () => {
const names = makeProvider().list();
const names = modelLookupNames(makeProvider().models());
for (const m of META_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -237,7 +233,7 @@ describe('MetaProvider model catalog', () => {
// would win bucket routing — and Meta trains on what it serves. It is
// also gated behind a separate enrolment, so a standard-tier key gets
// `model_not_found` for it.
const names = new Set(makeProvider().list());
const names = new Set(modelLookupNames(makeProvider().models()));
for (const name of names) {
expect(name).not.toContain('contributor');
}
@@ -19,12 +19,8 @@
import { OpenAI } from 'openai';
import type { Actor } from '../../../../core/actor.js';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
import type { IChatModel, ICompleteArguments } from '../../types.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
@@ -32,7 +28,6 @@ import {
type ChatProviderConfig,
OpenAICompatProvider,
} from '../OpenAICompatProvider.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js';
const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1';
@@ -61,16 +56,7 @@ const asRecord = (value: unknown): Record<string, unknown> =>
* models behind Responses- and Anthropic-Messages-shaped endpoints.
*/
export class MetaProvider extends OpenAICompatProvider {
#stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
#fsService: FSService;
constructor(
meteringService: MeteringService,
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
config: ChatProviderConfig,
) {
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
super(meteringService, {
client: new OpenAI({
apiKey: config.apiKey,
@@ -89,8 +75,6 @@ export class MetaProvider extends OpenAICompatProvider {
stripAnthropicShape: true,
compatParams: { toolChoiceAutoOnly: true },
});
this.#stores = stores;
this.#fsService = fsService;
}
override async complete(args: ICompleteArguments, resolved?: IChatModel) {
@@ -99,14 +83,6 @@ export class MetaProvider extends OpenAICompatProvider {
legacyCode: 'bad_request',
});
}
// Muse Spark reads images, video, PDFs and audio, but Chat Completions
// takes them inline only — resolve `puter_path` parts to data URLs.
await processPuterPathUploads(
args.messages,
this.#stores,
this.#fsService,
Context.get('actor'),
);
return super.complete(args, resolved);
}
@@ -36,6 +36,7 @@ import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { MINIMAX_MODELS } from './models.js';
import { MiniMaxProvider } from './MiniMaxProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
const { createMock, openAICtor } = vi.hoisted(() => {
const createMock = vi.fn();
@@ -133,7 +134,7 @@ describe('MiniMaxProvider model catalog', () => {
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const ids = provider.list();
const ids = modelLookupNames(provider.models());
for (const model of MINIMAX_MODELS) {
expect(ids).toContain(model.id);
for (const alias of model.aliases ?? []) {
@@ -54,6 +54,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { MISTRAL_MODELS } from './models.js';
import { MistralAIProvider } from './MistralAiProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── Mistral SDK mock ────────────────────────────────────────────────
//
@@ -166,7 +167,7 @@ describe('MistralAIProvider model catalog', () => {
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
for (const m of MISTRAL_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -180,14 +181,14 @@ describe('MistralAIProvider model catalog', () => {
it('does not redirect the retired Magistral ids to another model', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).not.toContain('magistral-small-latest');
expect(ids).not.toContain('magistral-medium-latest');
});
it('keeps mistral-large-2512, which Mistral still serves as active', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).toContain('mistral-large-2512');
expect(ids).toContain('mistral-large-latest');
});
@@ -36,7 +36,6 @@ import {
} from '../../utils/mediaParts.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { MISTRAL_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { shouldPresentAsOpenAI } from '../../utils/normalizeToOpenAI.js';
import { withSdkTimeout } from '../../utils/sdkClient.js';
import { meterChatUsage } from '../../utils/meterChatUsage.js';
@@ -130,10 +129,6 @@ export class MistralAIProvider implements IChatProvider {
return MISTRAL_MODELS;
}
async list() {
return modelLookupNames(await this.models());
}
/**
* The Mistral SDK validates camelCase fields, so canonical `image_url`
* parts become `imageUrl` (a snake_case key fails its schema client-side).
@@ -54,6 +54,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { MOONSHOT_MODELS } from './models.js';
import { MoonshotProvider } from './MoonshotProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -180,7 +181,7 @@ describe('MoonshotProvider model catalog', () => {
it('list() flattens canonical ids and aliases (returned via async)', async () => {
const { provider } = makeProvider();
const names = await provider.list();
const names = modelLookupNames(await provider.models());
for (const m of MOONSHOT_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
@@ -49,6 +49,7 @@ import {
stripNeuralwattPrefix,
} from './models.js';
import { NeuralwattProvider } from './NeuralwattProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -336,7 +337,7 @@ describe('NeuralwattProvider model catalog', () => {
it('list() prefixes ids and skips deprecated / pricing_tbd entries', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).toContain('neuralwatt:deepseek-v4-flash');
expect(ids).toContain('neuralwatt:zai-org/GLM-5.1-FP8');
expect(ids).toContain('GLM-5.1-FP8');
@@ -46,6 +46,7 @@ import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { OllamaChatProvider } from './OllamaProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// -- External boundaries ---------------------------------------------
@@ -233,7 +234,7 @@ describe('OllamaChatProvider model discovery', () => {
axiosRequestMock.mockResolvedValueOnce({
data: { models: [{ name: 'llama3.2' }, { name: 'qwen3' }] },
});
expect(await makeProvider().list()).toEqual([
expect(modelLookupNames(await makeProvider().models())).toEqual([
'ollama:ollama/llama3.2',
'ollama:ollama/qwen3',
]);
@@ -44,10 +44,8 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
() => {
const buildProvider = () =>
new OpenAiChatProvider(
makeMeteringStub(),
{ fsEntry: undefined as never, s3Object: undefined as never },
undefined as never,
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
it('returns a non-empty completion from gpt-4o-mini', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
@@ -56,6 +56,7 @@ import {
import { AIChatStream } from '../../utils/Streaming.js';
import { OPEN_AI_MODELS } from './models.js';
import { OpenAiChatProvider } from './OpenAiChatCompletionsProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -92,13 +93,8 @@ afterAll(async () => {
const makeProvider = () => {
const provider = new OpenAiChatProvider(
server.services.metering,
{
fsEntry: server.stores.fsEntry,
s3Object: server.stores.s3Object,
},
server.services.fs,
{ apiKey: 'test-key' },
server.services.metering,
);
return { provider };
};
@@ -187,7 +183,7 @@ describe('OpenAiChatProvider model catalog', () => {
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const ids = provider.list();
const ids = modelLookupNames(provider.models());
expect(ids).toContain('gpt-5.2-2025-12-11');
expect(ids).toContain('gpt-5.2');
expect(ids).toContain('openai/gpt-5.2');
@@ -21,10 +21,7 @@ import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type {
IChatModel,
IChatProvider,
@@ -40,10 +37,8 @@ import {
clampReasoningEffort,
openAICompatParams,
} from '../../utils/openaiParams.js';
import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import { meterChatUsage } from '../../utils/meterChatUsage.js';
@@ -70,21 +65,10 @@ export class OpenAiChatProvider implements IChatProvider {
#meteringService: MeteringService;
#stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
#fsService: FSService;
#responsesProvider: OpenAiResponsesChatProvider | null = null;
constructor(
meteringService: MeteringService,
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
config: { apiKey: string },
) {
constructor(config: { apiKey: string }, meteringService: MeteringService) {
this.#meteringService = meteringService;
this.#stores = stores;
this.#fsService = fsService;
this.#openAi = new OpenAI({
apiKey: config.apiKey,
...sdkClientOptions(),
@@ -107,10 +91,6 @@ export class OpenAiChatProvider implements IChatProvider {
return OPEN_AI_MODELS.filter((e) => !e.responses_api_only);
}
list() {
return modelLookupNames(this.models());
}
getDefaultModel() {
return this.#defaultModel;
}
@@ -201,16 +181,6 @@ export class OpenAiChatProvider implements IChatProvider {
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
// way to get user-provided files (images, audio) in front of the model.
await processPuterPathUploads(
messages,
this.#stores,
this.#fsService,
actor,
);
// Strip Anthropic-only shape a fallback-replayed message can carry
// (thinking/server-tool blocks, cache_control, citations) before the
// existing in-place coercion below, so that mutates only this pass's
@@ -57,6 +57,7 @@ import {
import { AIChatStream } from '../../utils/Streaming.js';
import { OPEN_AI_MODELS } from './models.js';
import { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -98,13 +99,8 @@ afterAll(async () => {
const makeProvider = () => {
const provider = new OpenAiResponsesChatProvider(
server.services.metering,
{
fsEntry: server.stores.fsEntry,
s3Object: server.stores.s3Object,
},
server.services.fs,
{ apiKey: 'test-key' },
server.services.metering,
);
return { provider };
};
@@ -254,7 +250,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
max_tokens: 128_000,
responses_api: true,
});
expect(provider.list()).toEqual(
expect(modelLookupNames(provider.models())).toEqual(
expect.arrayContaining([id, `openai/${id}`]),
);
},
@@ -272,7 +268,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
it('list() flattens canonical ids and aliases for Responses models', () => {
const { provider } = makeProvider();
const ids = provider.list();
const ids = modelLookupNames(provider.models());
expect(ids).toContain('gpt-5.6-sol');
expect(ids).toContain('openai/gpt-5.6-sol');
expect(ids).toContain('gpt-6-astra');
@@ -20,10 +20,7 @@
import { OpenAI } from 'openai';
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type {
IChatModel,
IChatProvider,
@@ -38,10 +35,8 @@ import {
rejectStatefulResponsesFields,
} from '../../utils/openaiParams.js';
import { responseSamplingParams } from '../../utils/responseSampling.js';
import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import { meterChatUsage } from '../../utils/meterChatUsage.js';
@@ -92,19 +87,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
#meteringService: MeteringService;
#stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
#fsService: FSService;
constructor(
meteringService: MeteringService,
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
config: { apiKey: string },
) {
constructor(config: { apiKey: string }, meteringService: MeteringService) {
this.#meteringService = meteringService;
this.#stores = stores;
this.#fsService = fsService;
this.#openAi = new OpenAI({
apiKey: config.apiKey,
...sdkClientOptions(),
@@ -125,10 +109,6 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
);
}
list() {
return modelLookupNames(this.models({ no_restrictions: false }));
}
getDefaultModel() {
return this.#defaultModel;
}
@@ -216,15 +196,6 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
await processPuterPathUploads(
messages,
this.#stores,
this.#fsService,
actor,
);
if (tools) {
// Unravel tools to OpenAI Responses API format
tools = (tools as any[]).map((e) => {
@@ -50,6 +50,7 @@ import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { OpenRouterProvider } from './OpenRouterProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -251,7 +252,7 @@ describe('OpenRouterProvider model catalog', () => {
it('list() prefixes ids with openrouter: and filters out openrouter/auto', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).toContain('openrouter:openai/gpt-6-luna');
expect(ids).toContain('openrouter:anthropic/claude-haiku-4.5');
expect(ids).not.toContain('openrouter:openrouter/auto');
@@ -259,7 +260,7 @@ describe('OpenRouterProvider model catalog', () => {
it('filters out models OpenRouter has scheduled for expiration', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
expect(ids).not.toContain('openrouter:qwen/qwen3-max');
expect(ids).not.toContain('qwen/qwen3-max');
});
@@ -259,9 +259,7 @@ const PROVIDERS: ProviderCase[] = [
name: 'meta',
dialect: 'chat',
make: () =>
new MetaProvider(metering(), stores, fsService, {
apiKey: 'k',
} as never),
new MetaProvider({ apiKey: 'k' }, metering()),
},
{
name: 'minimax',
@@ -301,17 +299,13 @@ const PROVIDERS: ProviderCase[] = [
name: 'openai-chat',
dialect: 'chat',
make: () =>
new OpenAiChatProvider(metering(), stores, fsService, {
apiKey: 'k',
}),
new OpenAiChatProvider({ apiKey: 'k' }, metering()),
},
{
name: 'openai-responses',
dialect: 'responses',
make: () =>
new OpenAiResponsesChatProvider(metering(), stores, fsService, {
apiKey: 'k',
}),
new OpenAiResponsesChatProvider({ apiKey: 'k' }, metering()),
},
{
name: 'openrouter',
@@ -51,6 +51,7 @@ import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { TogetherAIProvider } from './TogetherAIProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── Together SDK mock ───────────────────────────────────────────────
@@ -188,7 +189,7 @@ describe('TogetherAIProvider model catalog', () => {
it('list() flattens canonical ids and aliases for chat models only', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
const ids = modelLookupNames(await provider.models());
// Embedding-typed models are filtered out.
expect(ids).not.toContain('togetherai:some/embedding-model');
// Canonical id is prefixed with togetherai:
@@ -50,6 +50,7 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { ZAI_MODELS } from './models.js';
import { ZAIProvider } from './ZAIProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
//
@@ -186,7 +187,7 @@ describe('ZAIProvider model catalog', () => {
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const names = provider.list();
const names = modelLookupNames(provider.models());
for (const m of ZAI_MODELS) {
expect(names).toContain(m.id);
for (const a of m.aliases ?? []) {
-1
View File
@@ -291,7 +291,6 @@ export type IChatCompleteResult = IChatStreamResult | IChatMessageResult;
export interface IChatProvider {
models(extra_params?: unknown): IChatModel[] | Promise<IChatModel[]>;
list(): string[] | Promise<string[]>;
getDefaultModel(): string;
/**
* `model` is this provider's own catalog entry for `arg.model`, as the
+38 -103
View File
@@ -148,6 +148,38 @@ export const toOpenAIChatMessages = (
return out;
};
/**
* Moves a message's `tool_use` blocks into OpenAI `tool_calls`, in place. True
* when this call created `tool_calls`. `canonicalId` keeps the Responses item
* id a replayed call carries.
*/
const hoistToolUseBlocks = (msg, { canonicalId = false } = {}) => {
const content = msg.content;
let hoisted = false;
for (let i = content.length - 1; i >= 0; i--) {
const block = content[i];
if (block.type !== 'tool_use') continue;
if (!msg.tool_calls) {
msg.tool_calls = [];
hoisted = true;
}
msg.tool_calls.push({
id: block.id,
...(canonicalId ? { canonical_id: block.canonical_id } : {}),
type: 'function',
function: {
name: block.name,
arguments: JSON.stringify(block.input),
},
...(block.extra_content
? { extra_content: block.extra_content }
: {}),
});
content.splice(i, 1);
}
return hoisted;
};
/**
* Process input messages from Puter's normalized format to OpenAI's format May
* make changes in-place.
@@ -171,32 +203,7 @@ export const process_input_messages = async (messages) => {
}
}
// coerce tool calls
let is_tool_call = false;
for (let i = content.length - 1; i >= 0; i--) {
const content_block = content[i];
if (content_block.type === 'tool_use') {
if (!msg.tool_calls) {
msg.tool_calls = [];
is_tool_call = true;
}
msg.tool_calls.push({
id: content_block.id,
type: 'function',
function: {
name: content_block.name,
arguments: JSON.stringify(content_block.input),
},
...(content_block.extra_content
? { extra_content: content_block.extra_content }
: {}),
});
content.splice(i, 1);
}
}
if (is_tool_call) msg.content = null;
if (hoistToolUseBlocks(msg)) msg.content = null;
// coerce tool results
// (we assume multiple tool results were already split into separate messages)
@@ -380,10 +387,7 @@ export const process_input_messages_responses_api = async (messages) => {
}
}
// coerce tool calls
let is_tool_call = false;
for (let i = content.length - 1; i >= 0; i--) {
const content_block = content[i];
for (const content_block of content) {
if (
content_block.type === 'text' &&
(msg.role === 'user' || msg.role === 'system')
@@ -393,30 +397,9 @@ export const process_input_messages_responses_api = async (messages) => {
if (content_block.type === 'text' && msg.role === 'assistant') {
content_block.type = 'output_text';
}
if (content_block.type === 'tool_use') {
if (!msg.tool_calls) {
msg.tool_calls = [];
is_tool_call = true;
}
msg.tool_calls.push({
id: content_block.id,
canonical_id: content_block.canonical_id,
type: 'function',
function: {
name: content_block.name,
arguments: JSON.stringify(content_block.input),
},
...(content_block.extra_content
? { extra_content: content_block.extra_content }
: {}),
});
content.splice(i, 1);
}
}
if (is_tool_call) {
if (hoistToolUseBlocks(msg, { canonicalId: true })) {
// One Responses `function_call` item per tool_use block. A
// parallel tool-call turn used to keep only the first, which
// silently dropped every other call the model made — the
@@ -457,28 +440,6 @@ export const process_input_messages_responses_api = async (messages) => {
return flattened;
};
export const create_usage_calculator = ({ model_details }) => {
return ({ usage }) => {
const tokens = [];
tokens.push({
type: 'prompt',
model: model_details.id,
amount: usage.prompt_tokens,
cost: model_details.cost.input * usage.prompt_tokens,
});
tokens.push({
type: 'completion',
model: model_details.id,
amount: usage.completion_tokens,
cost: model_details.cost.output * usage.completion_tokens,
});
return tokens;
};
};
export const extractMeteredUsage = (usage) => {
return {
prompt_tokens: usage.prompt_tokens ?? 0,
@@ -715,24 +676,10 @@ export const create_chat_stream_handler =
};
export const create_chat_stream_handler_responses_api =
({ deviations, completion, usage_calculator }) =>
({ completion, usage_calculator }) =>
async ({ chatStream }) => {
deviations = Object.assign(
{
// affected by: Groq
index_usage_from_stream_chunk: (chunk) => chunk.usage,
// affected by: Mistral
chunk_but_like_actually: (chunk) => chunk,
index_tool_calls_from_stream_choice: (choice) =>
choice.delta.tool_calls,
},
deviations,
);
const message = chatStream.message();
const textblock = message.contentBlock({ type: 'text' });
let toolblock = null;
const mode = 'text';
let last_usage = null;
let completed = false;
@@ -844,7 +791,7 @@ export const create_chat_stream_handler_responses_api =
) {
sawFunctionCall = true;
const tool_call = chunk.item;
toolblock = message.contentBlock({
const toolblock = message.contentBlock({
type: 'tool_use',
canonical_id: tool_call.id,
id: tool_call.call_id,
@@ -881,8 +828,7 @@ export const create_chat_stream_handler_responses_api =
const usageDetails = usageDetailsFromTrackedUsage(usage);
if (usageDetails) chatStream.setUsageDetails(usageDetails);
if (mode === 'text') textblock.end();
if (mode === 'tool') toolblock.end();
textblock.end();
message.end();
chatStream.end(usage);
@@ -985,7 +931,6 @@ export const handle_completion_output = async (
/**
* @param {object} params
* @param {Record<string, unknown>} [params.deviations]
* @param {boolean} [params.stream]
* @param {any} params.completion
* @param {((text: string) => Promise<{ flagged: boolean }>) | undefined} [params.moderate]
@@ -999,24 +944,14 @@ export const handle_completion_output = async (
* @returns {ReturnType<import('../types').IChatProvider['complete']>}
*/
export const handle_completion_output_responses_api = async ({
deviations,
stream,
completion,
moderate,
usage_calculator,
finally_fn,
}) => {
deviations = Object.assign(
{
// affected by: Mistral
coerce_completion_usage: (completion) => completion.usage,
},
deviations,
);
if (stream) {
const init_chat_stream = create_chat_stream_handler_responses_api({
deviations,
completion,
usage_calculator,
});
@@ -25,7 +25,6 @@ import { withTestActor } from '../../integrationTestUtil.js';
import {
create_chat_stream_handler,
create_chat_stream_handler_responses_api,
create_usage_calculator,
extractMeteredUsage,
handle_completion_output,
handle_completion_output_responses_api,
@@ -778,36 +777,6 @@ describe('process_input_messages_responses_api', () => {
});
});
// ── create_usage_calculator ─────────────────────────────────────────
describe('create_usage_calculator', () => {
it('emits prompt + completion token rows priced by model_details.cost', () => {
const calc = create_usage_calculator({
model_details: {
id: 'gpt-test',
cost: { input: 0.01, output: 0.02 },
},
});
const tokens = calc({
usage: { prompt_tokens: 100, completion_tokens: 50 },
});
expect(tokens).toEqual([
{
type: 'prompt',
model: 'gpt-test',
amount: 100,
cost: 100 * 0.01,
},
{
type: 'completion',
model: 'gpt-test',
amount: 50,
cost: 50 * 0.02,
},
]);
});
});
// ── extractMeteredUsage ─────────────────────────────────────────────
describe('extractMeteredUsage', () => {