mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-21 19:11:10 +00:00
feat(ai): restore available Gemini models and align pricing
This commit is contained in:
1 parent
dda3c52fe2
commit
024d233164
11 files changed
+763
-139
No files matched your search
+107
-38
@@ -46,6 +46,47 @@ import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_GEMINI_API_KEY';
|
||||
|
||||
/** The reported output breakdown matches the tokens that were billed. */
|
||||
const expectBilledOutput = (
|
||||
usage: Record<string, number>,
|
||||
details: UsageDetails | undefined,
|
||||
) => {
|
||||
expect(usage.prompt_tokens).toBeGreaterThan(0);
|
||||
expect(usage.completion_tokens).toBeGreaterThan(0);
|
||||
expect(details?.outputTokens).toBe(
|
||||
usage.completion_tokens + usage.thinking_tokens,
|
||||
);
|
||||
expect(details?.reasoningTokens).toBe(usage.thinking_tokens);
|
||||
};
|
||||
|
||||
/** Runs a streamed result through `AIChatStream` and returns its events. */
|
||||
const collectStream = async (result: unknown) => {
|
||||
const lines: string[] = [];
|
||||
const chatStream = new AIChatStream({
|
||||
stream: new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
});
|
||||
await withTestActor(() =>
|
||||
(
|
||||
result as {
|
||||
init_chat_stream: (p: {
|
||||
chatStream: AIChatStream;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream }),
|
||||
);
|
||||
const events = lines
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
return { events, chatStream };
|
||||
};
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'GeminiChatProvider (integration)',
|
||||
() => {
|
||||
@@ -73,6 +114,70 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
// Four requests in all. Assertions never print responses or signatures.
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'GeminiChatProvider native Flash models (integration)',
|
||||
() => {
|
||||
const restored = (model: string, args: Partial<ICompleteArguments>) =>
|
||||
withTestActor(() =>
|
||||
new GeminiChatProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
).complete({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
reasoning_effort: 'high',
|
||||
// Thinking counts against the cap; leave room for it.
|
||||
max_tokens: 2048,
|
||||
...args,
|
||||
} as ICompleteArguments),
|
||||
);
|
||||
|
||||
it.each([
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'google/gemini-3-flash-preview',
|
||||
])(
|
||||
'answers %s at high thinking with explicit thought usage',
|
||||
async (model) => {
|
||||
const result = (await restored(
|
||||
model,
|
||||
{},
|
||||
)) as IChatMessageResult;
|
||||
|
||||
const text = result.message.content;
|
||||
expect(typeof text).toBe('string');
|
||||
expect((text as string).trim().length).toBeGreaterThan(0);
|
||||
expect(result.usageDetails?.reasoningTokens).toBeGreaterThan(0);
|
||||
expectBilledOutput(result.usage, result.usageDetails);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each([
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'google/gemini-3-flash-preview',
|
||||
])(
|
||||
'streams %s with a final explicit thought usage report',
|
||||
async (model) => {
|
||||
const { events, chatStream } = await collectStream(
|
||||
await restored(model, { stream: true }),
|
||||
);
|
||||
const usageLine = events.find((e) => e.type === 'usage');
|
||||
expect(events.some((e) => e.type === 'text')).toBe(true);
|
||||
expect(chatStream.reportedUsage).not.toBeNull();
|
||||
expect(usageLine.usageDetails.reasoningTokens).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expectBilledOutput(usageLine.usage, usageLine.usageDetails);
|
||||
},
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,18 +207,6 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
} as ICompleteArguments),
|
||||
);
|
||||
|
||||
const expectBilledOutput = (
|
||||
usage: Record<string, number>,
|
||||
details: UsageDetails | undefined,
|
||||
) => {
|
||||
expect(usage.prompt_tokens).toBeGreaterThan(0);
|
||||
expect(usage.completion_tokens).toBeGreaterThan(0);
|
||||
expect(details?.outputTokens).toBe(
|
||||
usage.completion_tokens + usage.thinking_tokens,
|
||||
);
|
||||
expect(details?.reasoningTokens).toBe(usage.thinking_tokens);
|
||||
};
|
||||
|
||||
it.each(['low', 'medium', 'high'] as const)(
|
||||
'answers at thinking level %s with explicit usage',
|
||||
async (effort) => {
|
||||
@@ -130,33 +223,9 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'streams with a final explicit usage report',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const result = await argon({
|
||||
stream: true,
|
||||
reasoning_effort: 'low',
|
||||
});
|
||||
const lines: string[] = [];
|
||||
const chatStream = new AIChatStream({
|
||||
stream: new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
}),
|
||||
});
|
||||
await withTestActor(() =>
|
||||
(
|
||||
result as {
|
||||
init_chat_stream: (p: {
|
||||
chatStream: AIChatStream;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream }),
|
||||
const { events, chatStream } = await collectStream(
|
||||
await argon({ stream: true, reasoning_effort: 'low' }),
|
||||
);
|
||||
const events = lines
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
const usageLine = events.find((e) => e.type === 'usage');
|
||||
expect(events.some((e) => e.type === 'text')).toBe(true);
|
||||
expect(chatStream.reportedUsage).not.toBeNull();
|
||||
|
||||
@@ -113,6 +113,11 @@ const makeProvider = (
|
||||
|
||||
const ARGON = GEMINI_MODELS.find((m) => m.id === 'gemini-4-argon')!;
|
||||
const LAUNCH = new Date('2026-10-14T12:00:00Z');
|
||||
const NATIVE_IDS = [
|
||||
'gemini-4-argon',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-3-flash-preview',
|
||||
];
|
||||
|
||||
// The usage Google returned for a live gemini-4-argon "Say OK" request.
|
||||
const LIVE_USAGE = {
|
||||
@@ -289,25 +294,28 @@ describe('gemini-4-argon catalog and routing', () => {
|
||||
apiBaseUrl: 'https://gemini-proxy.test/v1beta/openai/',
|
||||
});
|
||||
|
||||
it('leaves Argon out of the catalog and never builds the native client', async () => {
|
||||
it('leaves every native model out of the catalog and never builds the native client', async () => {
|
||||
const ids = (await proxied().models()).map((m) => m.id);
|
||||
expect(ids).not.toContain('gemini-4-argon');
|
||||
for (const id of NATIVE_IDS) expect(ids).not.toContain(id);
|
||||
expect(ids).toContain('gemini-3.7-flash');
|
||||
expect(genAICtor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects Argon without calling Google', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
proxied().complete({
|
||||
model: 'gemini-4-argon',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
expect(openAICreate).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(NATIVE_IDS)(
|
||||
'rejects %s without calling Google or the proxy',
|
||||
async (model) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
proxied().complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
expect(openAICreate).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('still serves the other Gemini models through the proxy', async () => {
|
||||
openAICreate.mockResolvedValueOnce({
|
||||
@@ -1306,6 +1314,196 @@ describe('gemini-4-argon tool calls', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The usage Google returned for live "Say OK" requests at the model default.
|
||||
const RESTORED = [
|
||||
{
|
||||
model: 'gemini-3.1-flash-lite',
|
||||
rates: { input: 25, output: 150, cached: 2.5 },
|
||||
usage: {
|
||||
promptTokenCount: 16,
|
||||
candidatesTokenCount: 3,
|
||||
thoughtsTokenCount: 206,
|
||||
totalTokenCount: 225,
|
||||
},
|
||||
},
|
||||
{
|
||||
model: 'gemini-3-flash-preview',
|
||||
rates: { input: 50, output: 300, cached: 5 },
|
||||
usage: {
|
||||
promptTokenCount: 16,
|
||||
candidatesTokenCount: 3,
|
||||
thoughtsTokenCount: 169,
|
||||
totalTokenCount: 188,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(RESTORED)('$model served natively', ({ model, rates, usage }) => {
|
||||
const entry = GEMINI_MODELS.find((m) => m.id === model)!;
|
||||
const thoughts = usage.thoughtsTokenCount;
|
||||
|
||||
it('lists text and image input only', () => {
|
||||
expect(entry).toMatchObject({
|
||||
puterId: `google:google/${model}`,
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
tool_call: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes the alias natively and bills candidates and thoughts separately', async () => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK', usage));
|
||||
const result = await complete({ model: `google/${model}` });
|
||||
|
||||
expect(requestOf().model).toBe(model);
|
||||
expect(requestOf().config).not.toHaveProperty('thinkingConfig');
|
||||
expect(openAICreate).not.toHaveBeenCalled();
|
||||
const [metered, , prefix, costs] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe(`gemini:${model}`);
|
||||
expect(metered).toEqual({
|
||||
prompt_tokens: 16,
|
||||
completion_tokens: 3,
|
||||
thinking_tokens: thoughts,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(costs).toEqual({
|
||||
prompt_tokens: 16 * rates.input,
|
||||
completion_tokens: 3 * rates.output,
|
||||
thinking_tokens: thoughts * rates.output,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(result.usageDetails).toEqual({
|
||||
inputTokens: 16,
|
||||
outputTokens: 3 + thoughts,
|
||||
reasoningTokens: thoughts,
|
||||
});
|
||||
expect((result.usageDetails as UsageDetails).outputTokens).toBe(
|
||||
trackedOutputTokens(metered, entry),
|
||||
);
|
||||
});
|
||||
|
||||
it('bills cached, prompt, candidate and thought tokens once each', async () => {
|
||||
generateContent.mockResolvedValueOnce(
|
||||
textResponse('OK', {
|
||||
promptTokenCount: 1000,
|
||||
cachedContentTokenCount: 400,
|
||||
candidatesTokenCount: 10,
|
||||
thoughtsTokenCount: 20,
|
||||
totalTokenCount: 1030,
|
||||
}),
|
||||
);
|
||||
const result = await complete({ model });
|
||||
|
||||
const [metered, , , costs] = recordSpy.mock.calls[0]!;
|
||||
expect(metered).toEqual({
|
||||
prompt_tokens: 600,
|
||||
completion_tokens: 10,
|
||||
thinking_tokens: 20,
|
||||
cached_tokens: 400,
|
||||
});
|
||||
expect(costs).toEqual({
|
||||
prompt_tokens: 600 * rates.input,
|
||||
completion_tokens: 10 * rates.output,
|
||||
thinking_tokens: 20 * rates.output,
|
||||
cached_tokens: 400 * rates.cached,
|
||||
});
|
||||
expect(result.usageDetails).toEqual({
|
||||
inputTokens: 600,
|
||||
outputTokens: 30,
|
||||
reasoningTokens: 20,
|
||||
cacheReadTokens: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['minimal', 'MINIMAL'],
|
||||
['low', 'LOW'],
|
||||
['medium', 'MEDIUM'],
|
||||
['high', 'HIGH'],
|
||||
])('sends reasoning_effort %s as thinkingLevel %s', async (effort, level) => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK', usage));
|
||||
await complete({
|
||||
model,
|
||||
reasoning_effort: effort as ICompleteArguments['reasoning_effort'],
|
||||
});
|
||||
expect(requestOf().config.thinkingConfig).toEqual({
|
||||
thinkingLevel: level,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['none', 'xhigh', 'max'])(
|
||||
'rejects reasoning_effort %s before calling Google',
|
||||
async (effort) => {
|
||||
await expect(
|
||||
complete({
|
||||
model,
|
||||
reasoning_effort: effort as ICompleteArguments['reasoning_effort'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 0.5])('forwards temperature %s', async (temperature) => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK', usage));
|
||||
await complete({ model, temperature });
|
||||
expect(requestOf().config.temperature).toBe(temperature);
|
||||
});
|
||||
|
||||
it('leaves temperature unset when the caller omits it', async () => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK', usage));
|
||||
await complete({ model });
|
||||
expect(requestOf().config).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('streams text and meters the final usage with its thoughts once', async () => {
|
||||
generateContentStream.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{
|
||||
candidates: [{ content: { role: 'model', parts: [{ text: 'O' }] } }],
|
||||
usageMetadata: { promptTokenCount: 16, totalTokenCount: 16 },
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'K' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: usage,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const { events } = await runStream({ model, reasoning_effort: 'high' });
|
||||
|
||||
expect(requestOf(generateContentStream).config.thinkingConfig).toEqual({
|
||||
thinkingLevel: 'HIGH',
|
||||
});
|
||||
expect(
|
||||
events.filter((e) => e.type === 'text').map((e) => e.text),
|
||||
).toEqual(['O', 'K']);
|
||||
expect(events.find((e) => e.type === 'usage')).toMatchObject({
|
||||
usage: {
|
||||
prompt_tokens: 16,
|
||||
completion_tokens: 3,
|
||||
thinking_tokens: thoughts,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
usageDetails: {
|
||||
inputTokens: 16,
|
||||
outputTokens: 3 + thoughts,
|
||||
reasoningTokens: thoughts,
|
||||
},
|
||||
});
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiNativeClient deadline', () => {
|
||||
type Request = { config?: { abortSignal?: AbortSignal } };
|
||||
// The SDK aborts its own controller without a reason.
|
||||
|
||||
@@ -53,6 +53,7 @@ import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { costsForRequest, listedCosts } from '../../utils/pricing.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -450,43 +451,167 @@ describe('GeminiChatProvider model resolution', () => {
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-3-flash-preview',
|
||||
])('drops the deprecated %s instead of redirecting it', async (model) => {
|
||||
it('drops the deprecated gemini-3.1-flash-lite-preview instead of redirecting it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = modelLookupNames(await provider.models());
|
||||
expect(ids).not.toContain(model);
|
||||
expect(ids).not.toContain(`google/${model}`);
|
||||
expect(ids).not.toContain('gemini-3.1-flash-lite-preview');
|
||||
expect(ids).not.toContain('google/gemini-3.1-flash-lite-preview');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Scheduled and prompt-size pricing -------------------------------
|
||||
|
||||
describe('GeminiChatProvider pricing tiers', () => {
|
||||
const completion = (usage: Record<string, unknown>) => ({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage,
|
||||
});
|
||||
|
||||
const settle = async (
|
||||
model: string,
|
||||
usage: Record<string, unknown>,
|
||||
pricingTime: Date,
|
||||
) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(completion(usage));
|
||||
await withTestActor(() =>
|
||||
provider.complete(
|
||||
{ model, messages: [{ role: 'user', content: 'hi' }] },
|
||||
undefined,
|
||||
pricingTime,
|
||||
),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].model).toBe(model);
|
||||
return recordSpy.mock.calls[0]![3];
|
||||
};
|
||||
|
||||
// gemini-3.5-flash and gemini-3.7-flash are served by 3.6 and 3.8 Flash.
|
||||
const SCHEDULED = [
|
||||
'gemini-3.5-flash',
|
||||
'gemini-3.6-flash',
|
||||
'gemini-3.7-flash',
|
||||
'gemini-3.8-flash',
|
||||
].flatMap((model) => [
|
||||
[model, '2026-12-31T23:59:59.999Z', 75, 375, 7.5] as const,
|
||||
[model, '2027-01-01T00:00:00.000Z', 150, 750, 15] as const,
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['gemini-3.1-pro-preview-customtools', 200, 1200, 20],
|
||||
['gemini-robotics-er-2-preview', 100, 500, 10],
|
||||
...SCHEDULED,
|
||||
['gemini-robotics-er-2-preview', '2026-12-31T23:59:59.999Z', 100, 500, 10] as const,
|
||||
['gemini-robotics-er-2-preview', '2027-01-01T00:00:00.000Z', 200, 1000, 20] as const,
|
||||
])(
|
||||
'sends %s under its own id and prices it at list rates',
|
||||
async (model, input, output, cached) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
'settles %s started at %s at its rates in force',
|
||||
async (model, iso, input, output, cached) => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 60,
|
||||
completion_tokens_details: { reasoning_tokens: 20 },
|
||||
prompt_tokens_details: { cached_tokens: 400 },
|
||||
},
|
||||
new Date(iso),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe(model);
|
||||
expect(
|
||||
GEMINI_MODELS.find((m) => m.id === model)?.costs,
|
||||
).toMatchObject({
|
||||
prompt_tokens: input,
|
||||
completion_tokens: output,
|
||||
cached_tokens: cached,
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 600 * input,
|
||||
completion_tokens: 40 * output,
|
||||
cached_tokens: 400 * cached,
|
||||
thinking_tokens: 20 * output,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['gemini-3.6-flash', 'gemini-robotics-er-2-preview'])(
|
||||
'lists %s at launch rates before the cutoff and doubled from it',
|
||||
(id) => {
|
||||
const model = GEMINI_MODELS.find((m) => m.id === id)!;
|
||||
const before = listedCosts(
|
||||
model,
|
||||
new Date('2026-12-31T23:59:59.999Z'),
|
||||
);
|
||||
const after = listedCosts(model, new Date('2027-01-01T00:00:00Z'));
|
||||
for (const key of [
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'thinking_tokens',
|
||||
'cached_tokens',
|
||||
]) {
|
||||
expect(after[key]).toBe(before[key]! * 2);
|
||||
}
|
||||
expect(after.grounding_requests).toBe(1_400_000);
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3.1-pro-preview-customtools',
|
||||
])('%s long-context tier', (model) => {
|
||||
const NOW = new Date('2026-10-14T12:00:00Z');
|
||||
|
||||
it('bills a 200,000-token prompt, cached tokens included, at base rates', async () => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 200_000,
|
||||
completion_tokens: 100,
|
||||
completion_tokens_details: { reasoning_tokens: 40 },
|
||||
prompt_tokens_details: { cached_tokens: 150_000 },
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50_000 * 200,
|
||||
completion_tokens: 60 * 1200,
|
||||
cached_tokens: 150_000 * 20,
|
||||
thinking_tokens: 40 * 1200,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('bills every token of a longer prompt at the long-context rates once', async () => {
|
||||
const overrides = await settle(
|
||||
model,
|
||||
{
|
||||
prompt_tokens: 200_001,
|
||||
completion_tokens: 100,
|
||||
completion_tokens_details: { reasoning_tokens: 40 },
|
||||
prompt_tokens_details: { cached_tokens: 150_000 },
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50_001 * 400,
|
||||
completion_tokens: 60 * 1800,
|
||||
cached_tokens: 150_000 * 40,
|
||||
thinking_tokens: 40 * 1800,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds and lists by the same tier the request settles at', () => {
|
||||
const entry = GEMINI_MODELS.find((m) => m.id === model)!;
|
||||
expect(costsForRequest(entry, 200_000, NOW)).toMatchObject({
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1200,
|
||||
cached_tokens: 20,
|
||||
});
|
||||
expect(costsForRequest(entry, 200_001, NOW)).toMatchObject({
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 1800,
|
||||
thinking_tokens: 1800,
|
||||
cached_tokens: 40,
|
||||
grounding_requests: 1_400_000,
|
||||
});
|
||||
expect(listedCosts(entry, NOW)).toBe(entry.costs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion ───────────────────────────────────────────
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
// Most models go through Google's OpenAI-compatible endpoint with the openai
|
||||
// SDK; only GEMINI_NATIVE_MODEL_IDS use the native generateContent API.
|
||||
// SDK; only GEMINI_NATIVE_MODELS use the native generateContent API.
|
||||
import {
|
||||
type GenerateContentResponseUsageMetadata,
|
||||
GoogleGenAI,
|
||||
@@ -43,16 +43,13 @@ import {
|
||||
import { geminiNativeClient } from './geminiNative.js';
|
||||
import {
|
||||
GEMINI_MODELS,
|
||||
GEMINI_NATIVE_MODEL_IDS,
|
||||
GEMINI_NATIVE_MODELS,
|
||||
GEMINI_ARGON_NON_STREAM_MAX_TOKENS,
|
||||
} from './models.js';
|
||||
|
||||
type GroundingContent = { grounding_metadata?: unknown };
|
||||
|
||||
/** The thinking levels the native models accept; medium when unset. */
|
||||
const NATIVE_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
|
||||
|
||||
/** Google's cap on non-streamed native output; streams keep the catalog's. */
|
||||
/** Unary Argon uses a lower output cap than streaming. */
|
||||
const nativeOutputCap = (
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
@@ -66,7 +63,7 @@ const COMPAT_BASE_URL =
|
||||
|
||||
/** The catalog without the native models, for a custom `apiBaseUrl`. */
|
||||
const COMPAT_ONLY_MODELS = GEMINI_MODELS.filter(
|
||||
(m) => !GEMINI_NATIVE_MODEL_IDS.has(m.id),
|
||||
(m) => !GEMINI_NATIVE_MODELS.has(m.id),
|
||||
);
|
||||
|
||||
export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
@@ -89,7 +86,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
completions: {
|
||||
create: (params, options) => {
|
||||
const { model } = params as { model: string };
|
||||
if (!GEMINI_NATIVE_MODEL_IDS.has(model)) {
|
||||
if (!GEMINI_NATIVE_MODELS.has(model)) {
|
||||
return compat.chat.completions.create(
|
||||
params,
|
||||
options,
|
||||
@@ -141,20 +138,26 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
) {
|
||||
if (!GEMINI_NATIVE_MODEL_IDS.has(model.id)) return params;
|
||||
const native = GEMINI_NATIVE_MODELS.get(model.id);
|
||||
if (!native) return params;
|
||||
// Unset leaves the thinking level to Google's per-model default.
|
||||
const effort = args.reasoning_effort ?? args.reasoning?.effort;
|
||||
if (effort !== undefined && !NATIVE_REASONING_EFFORTS.has(effort)) {
|
||||
if (effort !== undefined && !native.reasoningEfforts.has(effort)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`reasoning_effort must be low, medium or high for ${model.id}`,
|
||||
`reasoning_effort must be one of ${[...native.reasoningEfforts].join(', ')} for ${model.id}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const { temperature, ...shared } = params;
|
||||
const hasTools = Array.isArray(params.tools) && params.tools.length > 0;
|
||||
const outputCap = nativeOutputCap(args, model);
|
||||
const requested = params.max_completion_tokens as number | undefined;
|
||||
return {
|
||||
...params,
|
||||
...shared,
|
||||
...(native.temperature && temperature !== undefined
|
||||
? { temperature }
|
||||
: {}),
|
||||
...(outputCap !== undefined
|
||||
? {
|
||||
max_completion_tokens: Math.min(
|
||||
@@ -172,7 +175,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
|
||||
protected override preservesToolCallOrder(model: IChatModel) {
|
||||
// Gemini expects parallel calls back in order, signature on the first.
|
||||
return GEMINI_NATIVE_MODEL_IDS.has(model.id);
|
||||
return GEMINI_NATIVE_MODELS.has(model.id);
|
||||
}
|
||||
|
||||
protected override keepsToolCallExtraContent() {
|
||||
@@ -181,7 +184,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
if (GEMINI_NATIVE_MODEL_IDS.has(model.id)) {
|
||||
if (GEMINI_NATIVE_MODELS.has(model.id)) {
|
||||
return nativeMeteredUsage(
|
||||
source.usage as unknown as
|
||||
GenerateContentResponseUsageMetadata | undefined,
|
||||
|
||||
@@ -77,7 +77,8 @@ interface ChatParams {
|
||||
}>;
|
||||
tool_choice?: ChatToolChoice;
|
||||
max_completion_tokens?: number;
|
||||
reasoning_effort?: 'low' | 'medium' | 'high';
|
||||
temperature?: number;
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
@@ -238,13 +239,16 @@ const toGenerateContentRequest = (
|
||||
const toolConfig = functions.length
|
||||
? toolConfigOf(params.tool_choice)
|
||||
: undefined;
|
||||
// Sampling fields are not forwarded: Google ignores them on this model.
|
||||
const config: GenerateContentConfig = {
|
||||
...(signal ? { abortSignal: signal } : {}),
|
||||
...(systemInstruction ? { systemInstruction } : {}),
|
||||
...(params.max_completion_tokens !== undefined
|
||||
? { maxOutputTokens: params.max_completion_tokens }
|
||||
: {}),
|
||||
// The provider leaves temperature out for models that ignore it.
|
||||
...(params.temperature !== undefined
|
||||
? { temperature: params.temperature }
|
||||
: {}),
|
||||
...(functions.length
|
||||
? {
|
||||
tools: [
|
||||
|
||||
@@ -17,16 +17,83 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ModelCost,
|
||||
ModelPricingOverride,
|
||||
ReasoningEffort,
|
||||
} from '../../types.js';
|
||||
|
||||
/** Served natively: the OpenAI-compatible endpoint reports no thinking count. */
|
||||
export const GEMINI_NATIVE_MODEL_IDS: ReadonlySet<string> = new Set([
|
||||
'gemini-4-argon',
|
||||
]);
|
||||
/** What a natively served model accepts beyond the shared request fields. */
|
||||
export interface GeminiNativeOptions {
|
||||
reasoningEfforts: ReadonlySet<ReasoningEffort>;
|
||||
/** False where Google ignores temperature, so it isn't sent. */
|
||||
temperature: boolean;
|
||||
}
|
||||
|
||||
const FLASH_NATIVE_OPTIONS: GeminiNativeOptions = {
|
||||
reasoningEfforts: new Set(['minimal', 'low', 'medium', 'high']),
|
||||
temperature: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Served natively: the OpenAI-compatible endpoint omits these models' thinking
|
||||
* count from usage.
|
||||
*/
|
||||
export const GEMINI_NATIVE_MODELS: ReadonlyMap<string, GeminiNativeOptions> =
|
||||
new Map([
|
||||
[
|
||||
'gemini-4-argon',
|
||||
{
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high']),
|
||||
temperature: false,
|
||||
},
|
||||
],
|
||||
['gemini-3.1-flash-lite', FLASH_NATIVE_OPTIONS],
|
||||
['gemini-3-flash-preview', FLASH_NATIVE_OPTIONS],
|
||||
]);
|
||||
|
||||
// Gemini 3.6/3.8 Flash launch pricing; list pricing doubles from
|
||||
// 2027-01-01T00:00:00Z.
|
||||
const FLASH_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
// Gemini 3.x grounding is $14 / 1,000 requests
|
||||
grounding_requests: 1_400_000,
|
||||
};
|
||||
const FLASH_LIST_PRICING: ModelPricingOverride[] = [
|
||||
{
|
||||
startsAt: '2027-01-01T00:00:00Z',
|
||||
costs: {
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
thinking_tokens: 750,
|
||||
cached_tokens: 15,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Every rate rises for the whole request once the prompt, cached tokens
|
||||
// included, exceeds 200,000 tokens.
|
||||
const PRO_LONG_CONTEXT_PRICING: ModelPricingOverride[] = [
|
||||
{
|
||||
minPromptTokens: 200_000,
|
||||
costs: {
|
||||
prompt_tokens: 400,
|
||||
completion_tokens: 1800,
|
||||
thinking_tokens: 1800,
|
||||
cached_tokens: 40,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Google's unary model metadata reports this cap; streaming keeps the catalog cap.
|
||||
export const GEMINI_ARGON_NON_STREAM_MAX_TOKENS = 32_768;
|
||||
|
||||
|
||||
export const GEMINI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'google:google/gemini-4-argon',
|
||||
@@ -92,15 +159,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 900,
|
||||
thinking_tokens: 900,
|
||||
cached_tokens: 15,
|
||||
// Gemini 3.x grounding is $14 / 1,000 requests
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
// Google serves this endpoint with gemini-3.6-flash and bills its rates.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.5-flash-lite',
|
||||
@@ -153,14 +214,8 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
thinking_tokens: 750,
|
||||
cached_tokens: 15,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.7-flash',
|
||||
@@ -180,14 +235,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
// Google serves this endpoint with gemini-3.8-flash and bills its rates.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.8-flash',
|
||||
@@ -206,14 +256,60 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Launch pricing, listed as holding through 2026-12-31 and doubling
|
||||
// on 2027-01-01.
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.1-flash-lite',
|
||||
id: 'gemini-3.1-flash-lite',
|
||||
// The native driver has no audio or PDF part to forward.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-05-07',
|
||||
name: 'Gemini 3.1 Flash-Lite',
|
||||
aliases: ['google/gemini-3.1-flash-lite'],
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
completion_tokens: 375,
|
||||
thinking_tokens: 375,
|
||||
cached_tokens: 7.5,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 150,
|
||||
thinking_tokens: 150,
|
||||
cached_tokens: 2.5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3-flash-preview',
|
||||
id: 'gemini-3-flash-preview',
|
||||
// The native driver has no audio or PDF part to forward.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2025-12-17',
|
||||
name: 'Gemini 3 Flash Preview',
|
||||
aliases: ['google/gemini-3-flash-preview'],
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 300,
|
||||
thinking_tokens: 300,
|
||||
cached_tokens: 5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
},
|
||||
@@ -335,6 +431,7 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 20,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
pricingOverrides: PRO_LONG_CONTEXT_PRICING,
|
||||
max_tokens: 65536,
|
||||
},
|
||||
{
|
||||
@@ -364,6 +461,7 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 20,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
pricingOverrides: PRO_LONG_CONTEXT_PRICING,
|
||||
max_tokens: 65536,
|
||||
},
|
||||
{
|
||||
@@ -384,8 +482,7 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Launch pricing, listed as holding through 2026-12-31 and doubling
|
||||
// on 2027-01-01.
|
||||
// Launch pricing; list pricing doubles from 2027-01-01T00:00:00Z.
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
@@ -394,6 +491,17 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 10,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
pricingOverrides: [
|
||||
{
|
||||
startsAt: '2027-01-01T00:00:00Z',
|
||||
costs: {
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1000,
|
||||
thinking_tokens: 1000,
|
||||
cached_tokens: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemma-4-31b-it',
|
||||
|
||||
@@ -56,5 +56,23 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
expect(typeof result).toBe('string');
|
||||
expect((result as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns a 1K image from google/gemini-2.5-flash-image', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new GeminiImageProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'google/gemini-2.5-flash-image',
|
||||
prompt: 'a tiny red dot on a white background',
|
||||
ratio: { w: 1, h: 1 },
|
||||
quality: '1K',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatch(/^data:image\/[a-z]+;base64,.+/);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -207,6 +207,7 @@ describe('GeminiImageProvider.generate argument validation', () => {
|
||||
['gemini-3-pro-image', 'high'],
|
||||
['gemini-3-pro-image', ' 8K '],
|
||||
['gemini-3.1-flash-lite-image', '2K'],
|
||||
['gemini-2.5-flash-image', '2K'],
|
||||
])(
|
||||
'rejects an unsupported quality tier %s/%j before pricing',
|
||||
async (model, quality) => {
|
||||
@@ -319,7 +320,7 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
expect(generateContentMock.mock.calls[0]![0].model).toBe('gemini-3-pro-image');
|
||||
});
|
||||
|
||||
it.each(['gemini-2.5-flash-image', 'gemini-2.5-flash-image-preview', 'nano-banana'])(
|
||||
it.each(['gemini-2.5-flash-image-preview', 'nano-banana'])(
|
||||
'drops the deprecated %s instead of redirecting it',
|
||||
(model) => {
|
||||
const names = makeProvider()
|
||||
@@ -678,3 +679,68 @@ describe('Gemini Nano Banana image generation', () => {
|
||||
expect(makeProvider().getDefaultModel()).toBe('gemini-3.1-flash-image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini 2.5 Flash Image generation', () => {
|
||||
it('routes the google/ alias at 1K and meters the actual token split', async () => {
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'AAAA',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 1298,
|
||||
candidatesTokensDetails: [
|
||||
{ modality: 'IMAGE', tokenCount: 1290 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'google/gemini-2.5-flash-image',
|
||||
prompt: 'A cup',
|
||||
}),
|
||||
);
|
||||
expect(result).toBe('data:image/png;base64,AAAA');
|
||||
expect(generateContentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gemini-2.5-flash-image',
|
||||
config: expect.objectContaining({
|
||||
imageConfig: { aspectRatio: '1:1', imageSize: '1K' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Input + 1290 image tokens at $30/1M + 50 text tokens at $2.50/1M.
|
||||
const [, estimate] = hasCreditsSpy.mock.calls[0]!;
|
||||
expect(Math.abs(estimate - 3_882_530)).toBeLessThanOrEqual(1);
|
||||
expect(batchIncrementUsagesSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
[
|
||||
expect.objectContaining({
|
||||
usageType: 'gemini:gemini-2.5-flash-image:input',
|
||||
usageAmount: 11,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
usageType: 'gemini:gemini-2.5-flash-image:output:text',
|
||||
usageAmount: 8,
|
||||
costOverride: 2000,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
usageType: 'gemini:gemini-2.5-flash-image:output:image',
|
||||
usageAmount: 1290,
|
||||
costOverride: 3_870_000,
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,17 +40,21 @@ export const GEMINI_ESTIMATED_IMAGE_TOKENS: Record<string, number> = {
|
||||
'gemini-3.1-flash-image:4K': 2520,
|
||||
|
||||
'gemini-3.1-flash-lite-image:1K': 1120,
|
||||
|
||||
'gemini-2.5-flash-image:1K': 1290,
|
||||
};
|
||||
|
||||
// Estimated text and thinking output tokens per image for pre-flight cost
|
||||
// checks. Google bills these at the model's text output rate; samples taken
|
||||
// 2026-10-11 ran 380-425 on 3.1 Flash, about 400 on 3.1 Flash Lite, 270 on
|
||||
// 3 Pro, and 440-1122 on Nano Banana 2.1.
|
||||
// 3 Pro, and 440-1122 on Nano Banana 2.1. 2.5 Flash has no thinking, so its
|
||||
// estimate covers text only.
|
||||
export const GEMINI_ESTIMATED_TEXT_OUTPUT_TOKENS: Record<string, number> = {
|
||||
'gemini-nano-banana-2.1': 1000,
|
||||
'gemini-3-pro-image': 300,
|
||||
'gemini-3.1-flash-image': 450,
|
||||
'gemini-3.1-flash-lite-image': 450,
|
||||
'gemini-2.5-flash-image': 50,
|
||||
};
|
||||
|
||||
export const GEMINI_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
@@ -155,6 +159,35 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
'1K:1x1': 3.36, // 1120 tokens @ $30/1M = $0.0336 per 1K image
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-2.5-flash-image',
|
||||
id: 'gemini-2.5-flash-image',
|
||||
name: 'Gemini 2.5 Flash Image',
|
||||
version: '1.0',
|
||||
costs_currency: 'usd-cents',
|
||||
index_cost_key: '1K:1x1',
|
||||
index_input_cost_key: 'input',
|
||||
aliases: ['google/gemini-2.5-flash-image'],
|
||||
allowedQualityLevels: ['1K'],
|
||||
allowedRatios: [
|
||||
{ w: 1, h: 1 },
|
||||
{ w: 2, h: 3 },
|
||||
{ w: 3, h: 2 },
|
||||
{ w: 3, h: 4 },
|
||||
{ w: 4, h: 3 },
|
||||
{ w: 4, h: 5 },
|
||||
{ w: 5, h: 4 },
|
||||
{ w: 9, h: 16 },
|
||||
{ w: 16, h: 9 },
|
||||
{ w: 21, h: 9 },
|
||||
],
|
||||
costs: {
|
||||
input: 30, // $0.30 per 1M input tokens (text/image)
|
||||
output: 250, // $2.50 per 1M output tokens (text)
|
||||
output_image: 3000, // $30.00 per 1M output image tokens
|
||||
'1K:1x1': 3.87, // 1290 tokens @ $30/1M = $0.0387 per 1K image
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3-pro-image',
|
||||
id: 'gemini-3-pro-image',
|
||||
|
||||
@@ -32,9 +32,9 @@ An object containing the following properties:
|
||||
- `signal` (AbortSignal) (Optional) - Abort the request, including while waiting for the next streamed chunk. Cancellation rejects with the signal reason (normally an `AbortError`).
|
||||
- `stream` (Boolean) - A boolean indicating whether you want to stream the completion. Defaults to `false`.
|
||||
- `max_tokens` (Number) - The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used.
|
||||
- `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6 and GPT-6 Sol/Luna accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too.
|
||||
- `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6 and GPT-6 Sol/Luna accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it.
|
||||
- `tools` (Array) (Optional) - Function definitions the AI can call. See [Function Calling](#function-calling) for details.
|
||||
- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Lower values give faster responses with less reasoning. OpenAI models, Meta's Muse Spark models and `gemini-4-argon` only; Muse Spark always reasons, so `none` is ignored for it. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. Any other value fails with `400 bad_request`.
|
||||
- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Lower values give faster responses with less reasoning. OpenAI models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only.
|
||||
- `verbosity` / `text.verbosity` (String) (Optional) - Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only.
|
||||
- `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter` — or the vendor's own stop reason, passed through unchanged when it has no OpenAI equivalent. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, the SDK-wide `puter.ai.normalize` applies — itself tri-state: set it to `true` to normalize every call regardless of release date, `false` to disable normalization for every call, and when it is left unset too (the default) the release-date policy applies: **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and for older models the default is unchanged — `message.content` keeps its vendor-native shape. (A handful of reasoning fields were made consistent across all models independently of this option; see [Reasoning fields on existing models](#reasoning-fields-on-existing-models).) Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization).
|
||||
- `compaction` (Boolean | Object) (Optional) - Opt into inline context compaction for long conversations. Pass `true` to enable it with provider defaults, or `{ trigger_tokens: number }` to set the token threshold at which earlier context is summarized. When the model compacts, you receive a `compaction` chunk while streaming (or a `compaction` field on the result when not streaming) containing an opaque `encrypted_content` summary. Resend that item in `messages` on the next turn in place of the summarized history. The compaction chunk shape is identical across providers, so the same code works whether `model` is an OpenAI or Anthropic model. See [Compaction](#compaction).
|
||||
|
||||
@@ -110,9 +110,9 @@ Available when `provider: 'gemini'` or inferred from model:
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | `'gemini-3.1-flash-image'` (default), `'gemini-nano-banana-2.1'` (Nano Banana 2.1), `'gemini-3.1-flash-lite-image'`, or `'gemini-3-pro-image'`. |
|
||||
| `model` | `String` | `'gemini-3.1-flash-image'` (default), `'gemini-nano-banana-2.1'` (Nano Banana 2.1), `'gemini-3.1-flash-lite-image'`, `'gemini-2.5-flash-image'`, or `'gemini-3-pro-image'`. |
|
||||
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
|
||||
| `quality` | `String` | Nano Banana 2.1 and 3 Pro: `'1K'`, `'2K'`, `'4K'`; 3.1 Flash: `'512'`, `'1K'`, `'2K'`, `'4K'`; 3.1 Flash Lite: `'1K'`. Case-insensitive; a tier the model does not offer returns `bad_request` before any credits are checked. Defaults to the first supported tier: `'512'` on 3.1 Flash, `'1K'` on Nano Banana 2.1, 3 Pro, and 3.1 Flash Lite. |
|
||||
| `quality` | `String` | Nano Banana 2.1 and 3 Pro: `'1K'`, `'2K'`, `'4K'`; 3.1 Flash: `'512'`, `'1K'`, `'2K'`, `'4K'`; 3.1 Flash Lite and 2.5 Flash: `'1K'`. Case-insensitive; a tier the model does not offer returns `bad_request` before any credits are checked. Defaults to the first supported tier: `'512'` on 3.1 Flash, `'1K'` on Nano Banana 2.1, 3 Pro, 3.1 Flash Lite, and 2.5 Flash. |
|
||||
| `input_images` | `Array<String>` | Input images for image-to-image, as URLs or base64/data-URIs. |
|
||||
|
||||
#### xAI (Grok) Options
|
||||
|
||||
Reference in new issue
Block a user