fix PUT-1444 (#3515)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Neal Shah
2026-08-06 17:11:04 -04:00
committed by GitHub
parent ff16ec8900
commit edfd67a83c
4 changed files with 406 additions and 26 deletions
@@ -0,0 +1,193 @@
/**
* 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/](https://www.gnu.org/licenses/).
*/
/**
* Which provider actually serves a model that several providers advertise.
*
* Lives apart from ChatCompletionDriver.test.ts because `vi.mock` is
* file-scoped and hoisted — mocking the OpenAI SDK and axios here would
* otherwise leak into every test in that file. Both mocks sit at the real
* network egress points, so the driver's registration, model-map build and
* resolution all run for real.
*/
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { kv } from '../../util/kvSingleton.js';
import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
// -- OpenAI SDK mock ------------------------------------------------
// Gemini reaches Google through `new openai.OpenAI()` (default export) and
// the gateway through the named one, so both must resolve to the same ctor.
const { createMock } = vi.hoisted(() => ({ createMock: vi.fn() }));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
) {
this.chat = { completions: { create: createMock } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// -- axios mock (gateway model catalog) -----------------------------
const { axiosRequestMock } = vi.hoisted(() => ({ axiosRequestMock: vi.fn() }));
vi.mock('axios', () => ({
default: { request: axiosRequestMock },
request: axiosRequestMock,
}));
// -- Harness --------------------------------------------------------
let server: PuterServer;
let driver: ChatCompletionDriver;
const INFRON_KV_KEY = 'infronChat:models';
// Google lists gemini-2.5-flash input at $0.30/MTok. The gateway quotes a
// floor price across its upstream routes, so it undercuts — which is exactly
// the condition that used to hand it the traffic.
const GATEWAY_CATALOG = [
{
id: 'google/gemini-2.5-flash',
display_name: 'Google: Gemini 2.5 Flash',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 1_048_576,
max_output_tokens: 65_536,
min_prompt_price: 0.15,
min_completion_price: 1.0,
},
{
// Only the gateway carries this one — no first-party counterpart.
id: 'google/gemini-2.5-flash-image-preview',
display_name: 'Google: Gemini 2.5 Flash Image Preview',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 32_768,
max_output_tokens: 8_192,
min_prompt_price: 0.3,
min_completion_price: 2.5,
},
];
beforeAll(async () => {
server = await setupTestServer();
kv.del?.(INFRON_KV_KEY);
axiosRequestMock.mockResolvedValue({ data: { data: GATEWAY_CATALOG } });
// Built once, not per-test: `#buildModelMap` mutates the catalogs
// providers hand back (lowercasing ids, pushing `puterId` onto the
// shared `aliases` array), and GeminiChatProvider returns its
// module-level GEMINI_MODELS by reference.
driver = new ChatCompletionDriver(
{
providers: {
gemini: { apiKey: 'test-key' },
infron: { apiKey: 'test-key' },
ollama: { enabled: false },
},
} as never,
server.clients,
server.stores,
server.services,
);
driver.onServerStart();
// `onServerStart` doesn't await `#buildModelMap`, and the gateway
// catalog resolves on a microtask — poll until both providers land.
for (let i = 0; i < 200; i++) {
const ids = await driver.list();
if (ids.some((id) => id.startsWith('infron:'))) break;
await new Promise((r) => setTimeout(r, 5));
}
});
afterAll(async () => {
await server?.shutdown();
});
/**
* Route a request and report who was tried, in order. Forcing the upstream to
* reject is what makes the whole chain observable: the driver records every
* attempt on the thrown error, and `attempts[0]` is who it chose first.
*/
const attemptsFor = async (model: string) => {
createMock.mockRejectedValue(new Error('upstream down'));
let caught: HttpError | undefined;
try {
await withTestActor(() =>
driver.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
}),
);
} catch (e) {
caught = e as HttpError;
}
expect(caught).toBeInstanceOf(HttpError);
return (caught as unknown as { fields: { attempts: { model: string; provider: string }[] } })
.fields.attempts;
};
describe('ChatCompletionDriver gemini routing', () => {
it('serves gemini models from Google, with the gateway only as fallback', async () => {
const attempts = await attemptsFor('gemini-2.5-flash');
expect(attempts[0]).toMatchObject({
provider: 'gemini',
model: 'gemini-2.5-flash',
});
expect(attempts[1]).toMatchObject({
provider: 'infron',
model: 'infron:google/gemini-2.5-flash',
});
});
it('routes the prefixed and puterId forms to Google too', async () => {
for (const alias of [
'google/gemini-2.5-flash',
'google:google/gemini-2.5-flash',
]) {
const attempts = await attemptsFor(alias);
expect(attempts[0].provider).toBe('gemini');
}
});
it('still routes models only the gateway carries to the gateway', async () => {
const attempts = await attemptsFor('google/gemini-2.5-flash-image-preview');
expect(attempts[0]).toMatchObject({ provider: 'infron' });
});
});
@@ -61,6 +61,10 @@ import {
normalize_messages,
normalize_single_message,
} from './utils/Messages.js';
import {
AGGREGATOR_PROVIDERS,
compareModelPreference,
} from './utils/modelRouting.js';
import { AIChatStream } from './utils/Streaming.js';
const MAX_FALLBACKS = 4; // includes first attempt
@@ -1019,16 +1023,9 @@ export class ChatCompletionDriver extends PuterDriver {
// -- Model map ---------------------------------------------------
async #buildModelMap() {
const AGGREGATORS = new Set([
'together-ai',
'openrouter',
'infron',
'neuralwatt',
]);
for (const providerName in this.#providers) {
const provider = this.#providers[providerName];
const isAggregator = AGGREGATORS.has(providerName);
const isAggregator = AGGREGATOR_PROVIDERS.has(providerName);
for (const model of await provider.models()) {
model.id = model.id.trim().toLowerCase();
@@ -1058,8 +1055,11 @@ export class ChatCompletionDriver extends PuterDriver {
existing !== this.#modelIdMap[model.id]
) {
if (existing.some((m) => m.provider === 'gemini')) {
// Gemini exception — let the aggregator
// entry through.
// Gemini is the one vendor whose resold
// duplicates we keep, so a Google outage has
// somewhere to fall back to. Ranking (see
// `compareModelPreference`) keeps the direct
// provider ahead of them.
continue;
}
skip = true;
@@ -1098,22 +1098,7 @@ export class ChatCompletionDriver extends PuterDriver {
}
}
// Sort: together-ai always last; then cheapest input-cost
// first; ties break by shorter id (usually the official
// name over a long aggregator-qualified one).
this.#modelIdMap[model.id].sort((a, b) => {
const aAgg = a.provider === 'together-ai';
const bAgg = b.provider === 'together-ai';
if (aAgg !== bAgg) return aAgg ? 1 : -1;
const aCost = a.costs[
(a.input_cost_key as string) || 'input_tokens'
] as number;
const bCost = b.costs[
(b.input_cost_key as string) || 'input_tokens'
] as number;
if (aCost === bCost) return a.id.length - b.id.length;
return aCost - bCost;
});
this.#modelIdMap[model.id].sort(compareModelPreference);
}
}
}
@@ -0,0 +1,135 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { GEMINI_MODELS } from '../providers/gemini/models.js';
import type { IChatModel } from '../types.js';
import { compareModelPreference } from './modelRouting.js';
// `#buildModelMap` mutates the catalogs providers hand back, and
// `GeminiChatProvider.models()` returns the module-level `GEMINI_MODELS` by
// reference — clone so these fixtures can't be perturbed by another suite.
const geminiModel = (id: string, provider = 'gemini'): IChatModel => {
const found = GEMINI_MODELS.find((m) => m.id === id);
if (!found) throw new Error(`no such gemini model: ${id}`);
return { ...structuredClone(found), provider };
};
// Mirrors how the reseller providers coerce a catalog entry: `<gateway>:` on
// the id, `input_cost_key: 'prompt'`, and prices as microcents per token.
const resoldModel = (
provider: string,
catalogId: string,
promptCost: number,
): IChatModel =>
({
id: `${provider}:${catalogId}`,
name: `${catalogId} (${provider})`,
aliases: [catalogId, catalogId.split('/').slice(1).join('/')],
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: { tokens: 1_000_000, prompt: promptCost, completion: 100 },
provider,
}) as IChatModel;
const winner = (...models: IChatModel[]) =>
[...models].sort(compareModelPreference)[0];
describe('compareModelPreference', () => {
it('serves the vendor directly even when a reseller quotes a lower price', () => {
// Google lists gemini-2.5-flash input at 30 microcents/token; the
// gateway advertises a floor price across its upstream routes and
// undercuts it. Price must not decide who serves the request.
const direct = geminiModel('gemini-2.5-flash');
const resold = resoldModel('infron', 'google/gemini-2.5-flash', 15);
expect(direct.costs.prompt_tokens).toBeGreaterThan(
resold.costs.prompt as number,
);
expect(winner(resold, direct).provider).toBe('gemini');
expect(winner(direct, resold).provider).toBe('gemini');
});
it('ranks every reseller behind the vendor, not just one of them', () => {
const direct = geminiModel('gemini-3.1-pro-preview');
const bucket = [
resoldModel('openrouter', 'google/gemini-3.1-pro-preview', 90),
resoldModel('infron', 'google/gemini-3.1-pro-preview', 80),
resoldModel('together-ai', 'google/gemini-3.1-pro-preview', 70),
resoldModel('neuralwatt', 'google/gemini-3.1-pro-preview', 60),
direct,
];
expect(
bucket.sort(compareModelPreference).map((m) => m.provider),
).toEqual([
'gemini',
'neuralwatt',
'infron',
'openrouter',
'together-ai',
]);
});
it('keeps together-ai strictly behind the other resellers', () => {
// A flat direct/reseller split would let these tie and re-order by
// price; together-ai stays last regardless of how cheap it quotes.
const together = resoldModel('together-ai', 'meta/llama-4', 1);
const openrouter = resoldModel('openrouter', 'meta/llama-4', 500);
expect(winner(together, openrouter).provider).toBe('openrouter');
});
it('still orders two direct providers by cheapest input cost', () => {
const cheap = geminiModel('gemini-2.5-flash-lite');
const pricey = geminiModel('gemini-2.5-pro');
expect(cheap.costs.prompt_tokens).toBeLessThan(
pricey.costs.prompt_tokens as number,
);
expect(winner(pricey, cheap).id).toBe('gemini-2.5-flash-lite');
});
it('breaks price ties on the shorter id', () => {
const short = { ...geminiModel('gemini-2.5-flash'), id: 'gemini-x' };
const long = {
...geminiModel('gemini-2.5-flash'),
id: 'some-vendor/gemini-x-2025-preview',
provider: 'azure-openai',
};
expect(winner(long, short).id).toBe('gemini-x');
});
it('leaves a reseller serving models no vendor provider carries', () => {
// The image-preview models are absent from GEMINI_MODELS, so the
// gateway is the only route and must stay the winner.
expect(
GEMINI_MODELS.some((m) => m.id === 'gemini-2.5-flash-image-preview'),
).toBe(false);
const onlyRoute = resoldModel(
'infron',
'google/gemini-2.5-flash-image-preview',
20,
);
expect(winner(onlyRoute).provider).toBe('infron');
});
});
@@ -0,0 +1,67 @@
/**
* 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/](https://www.gnu.org/licenses/).
*/
import type { IChatModel } from '../types.js';
/**
* Providers that resell other vendors' models rather than serving their own.
* Their catalogs duplicate models we already reach directly, so their entries
* are kept only as fallback routes.
*/
export const AGGREGATOR_PROVIDERS = new Set([
'together-ai',
'openrouter',
'infron',
'neuralwatt',
]);
// Lower rank is served first. `together-ai` sits behind the other resellers —
// a pre-existing guarantee this ranking extends rather than replaces.
const providerRank = (provider?: string): number => {
if (provider === 'together-ai') return 2;
if (provider && AGGREGATOR_PROVIDERS.has(provider)) return 1;
return 0;
};
/**
* Orders the candidates that share a model bucket; the first one gets served.
*
* Direct vendors outrank resellers regardless of quoted price. Resellers
* advertise a floor price across their upstream routes, so on price alone they
* undercut the vendor's list price and capture traffic for models we hold a
* direct integration for. Within a rank, cheapest input cost wins and ties
* break by shorter id usually the official name over a qualified one.
*/
export const compareModelPreference = (
a: IChatModel,
b: IChatModel,
): number => {
const rankDiff = providerRank(a.provider) - providerRank(b.provider);
if (rankDiff !== 0) return rankDiff;
const aCost = a.costs[
(a.input_cost_key as string) || 'input_tokens'
] as number;
const bCost = b.costs[
(b.input_cost_key as string) || 'input_tokens'
] as number;
if (aCost === bCost) return a.id.length - b.id.length;
return aCost - bCost;
};