diff --git a/config.template.jsonc b/config.template.jsonc
index 56a0268d3..9c573fad6 100644
--- a/config.template.jsonc
+++ b/config.template.jsonc
@@ -346,6 +346,10 @@
"apiURL": ""
},
"gemini": { "apiKey": "" },
+ // Meta Model API (Muse Spark). `muse-spark-1.2-contributor`
+ // trades a ~12x discount for Meta training on its prompts and
+ // completions, so it is only served to callers that name it.
+ "meta": { "apiKey": "" },
"groq": { "apiKey": "" },
"deepseek": { "apiKey": "" },
"mistral": { "apiKey": "" },
diff --git a/package-lock.json b/package-lock.json
index a434eaa74..8e0023f68 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1304,7 +1304,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
- "extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -19474,7 +19473,7 @@
},
"src/cli": {
"name": "@heyputer/cli",
- "version": "0.1.2",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.7.0",
diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts
index d0eff6479..c9d2229ee 100644
--- a/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts
+++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.edges.test.ts
@@ -62,6 +62,7 @@ const FULL_PROVIDER_CONFIG = {
'azure-openai': { apiKey: 'k', apiURL: 'https://azure.test/openai/v1' },
'openai-completion': { apiKey: 'k' },
gemini: { apiKey: 'k' },
+ meta: { apiKey: 'k' },
groq: { apiKey: 'k' },
deepseek: { apiKey: 'k' },
mistral: { apiKey: 'k' },
@@ -174,6 +175,7 @@ describe('ChatCompletionDriver provider registration', () => {
'azure-openai',
'openai-completion',
'gemini',
+ 'meta',
'groq',
'deepseek',
'mistral',
diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.routing.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.routing.test.ts
index 13da0ea45..95eb247fc 100644
--- a/src/backend/drivers/ai-chat/ChatCompletionDriver.routing.test.ts
+++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.routing.test.ts
@@ -128,6 +128,10 @@ const OPENROUTER_CATALOG = [
'deepseek/deepseek-v4-pro',
'deepseek-ai/deepseek-v4-pro',
'google/gemini-2.5-flash',
+ // OpenRouter really does carry Muse Spark under Meta's own id, which is
+ // also the alias the Meta provider publishes — the two have to land in
+ // one bucket rather than becoming separate models.
+ 'meta/muse-spark-1.2',
].map((id) => ({
id,
name: `${id} (via OpenRouter)`,
@@ -158,6 +162,7 @@ beforeAll(async () => {
providers: {
gemini: { apiKey: 'test-key' },
deepseek: { apiKey: 'test-key' },
+ meta: { apiKey: 'test-key' },
infron: { apiKey: 'test-key' },
openrouter: { apiKey: 'test-key' },
ollama: { enabled: false },
@@ -250,6 +255,26 @@ describe('ChatCompletionDriver gemini routing', () => {
});
});
+describe('ChatCompletionDriver muse spark routing', () => {
+ it('serves Muse Spark from Meta, with OpenRouter only as fallback', async () => {
+ const attempts = await attemptsFor('muse-spark-1.2');
+
+ expect(attempts[0]).toMatchObject({
+ provider: 'meta',
+ model: 'muse-spark-1.2',
+ });
+ expect(attempts[1]).toMatchObject({
+ provider: 'openrouter',
+ model: 'openrouter:meta/muse-spark-1.2',
+ });
+ });
+
+ it('routes the vendor-qualified alias to Meta too', async () => {
+ const attempts = await attemptsFor('meta/muse-spark-1.2');
+ expect(attempts[0].provider).toBe('meta');
+ });
+});
+
describe('ChatCompletionDriver duplicate-model fallback', () => {
// deepseek-v4-pro is served directly by DeepSeek, by Infron, and twice by
// OpenRouter (two upstream orgs) — four routes in one bucket.
diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
index 90ce3840c..73b024dce 100644
--- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
+++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
@@ -42,6 +42,7 @@ import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
import { InfronProvider } from './providers/infron/InfronProvider.js';
+import { MetaProvider } from './providers/meta/MetaProvider.js';
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
@@ -1133,6 +1134,23 @@ export class ChatCompletionDriver extends PuterDriver {
});
}
+ const meta = providers['meta'];
+ 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,
+ },
+ );
+ }
+
const groqKey = readKey(providers['groq']);
if (groqKey) {
this.#providers['groq'] = new GroqAIProvider(
diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.integration.test.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.integration.test.ts
new file mode 100644
index 000000000..127612114
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.integration.test.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 .
+ */
+
+/**
+ * Integration test for the Meta Model API (Muse Spark) provider.
+ *
+ * Muse Spark always reasons, and those tokens come out of the same output
+ * budget, so a tight `max_tokens` comes back as `content: null` with
+ * `finish_reason: 'length'` — hence the generous cap on a one-word prompt.
+ * Skipped when `PUTER_TEST_AI_META_API_KEY` is unset.
+ */
+
+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,
+ optionalEnv,
+ skipUnlessEnv,
+ withTestActor,
+} from '../../../integrationTestUtil.js';
+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)! },
+ );
+
+ const result = await withTestActor(() =>
+ provider.complete({
+ model: 'muse-spark-1.2',
+ messages: [
+ { role: 'user', content: 'Say hi in one word.' },
+ ],
+ max_tokens: 2048,
+ reasoning_effort: 'low',
+ }),
+ );
+
+ const text = (result as { message?: { content?: string } }).message
+ ?.content;
+ expect(typeof text === 'string' && text.length > 0).toBe(true);
+ },
+ );
+});
diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts
new file mode 100644
index 000000000..1af959b42
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.test.ts
@@ -0,0 +1,744 @@
+/*
+ * 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 .
+ */
+
+/**
+ * Offline unit tests for MetaProvider.
+ *
+ * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
+ * constructs MetaProvider against the live wired `MeteringService` so the
+ * recording side is exercised end-to-end. The OpenAI SDK is mocked at the
+ * module boundary — Meta's Model API is OpenAI-compatible, so the provider
+ * reaches it through the same client — meaning nothing leaves the process. The
+ * companion integration test (MetaProvider.integration.test.ts) exercises the
+ * real api.meta.ai endpoint.
+ */
+
+import { Writable } from 'node:stream';
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+ type MockInstance,
+} from 'vitest';
+
+import type { Actor } from '../../../../core/actor.js';
+import { SYSTEM_ACTOR } from '../../../../core/actor.js';
+import type { MeteringService } from '../../../../services/metering/MeteringService.js';
+import { PuterServer } from '../../../../server.js';
+import { setupTestServer } from '../../../../testUtil.js';
+import { withTestActor } from '../../../integrationTestUtil.js';
+import { AIChatStream } from '../../utils/Streaming.js';
+import { MetaProvider } from './MetaProvider.js';
+import { META_MODELS } from './models.js';
+
+// -- OpenAI SDK mock ----------------------------------------------
+//
+// `vi.hoisted` shares spies between the hoisted factory and the test body so
+// each test can stub `chat.completions.create` with the response shape it
+// cares about.
+
+const { createMock, openAICtor } = vi.hoisted(() => {
+ const createMock = vi.fn();
+ const openAICtor = vi.fn();
+ return { createMock, openAICtor };
+});
+
+vi.mock('openai', () => {
+ const OpenAICtor = vi.fn().mockImplementation(function (
+ this: Record,
+ opts: unknown,
+ ) {
+ openAICtor(opts);
+ this.chat = { completions: { create: createMock } };
+ });
+ // The test server boots every provider, and some (e.g. OllamaChatProvider)
+ // import the default export and read `.OpenAI` off it — expose both shapes.
+ return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
+});
+
+// -- Test harness -------------------------------------------------
+
+let server: PuterServer;
+let recordSpy: MockInstance;
+
+beforeAll(async () => {
+ server = await setupTestServer();
+});
+
+afterAll(async () => {
+ await server?.shutdown();
+});
+
+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 } : {}),
+ },
+ );
+
+const asAsyncIterable = (items: T[]): AsyncIterable => ({
+ async *[Symbol.asyncIterator]() {
+ for (const item of items) {
+ yield item;
+ }
+ },
+});
+
+const makeCapturingChatStream = () => {
+ const chunks: string[] = [];
+ const sink = new Writable({
+ write(chunk, _enc, cb) {
+ chunks.push(chunk.toString('utf8'));
+ cb();
+ },
+ });
+ const chatStream = new AIChatStream({ stream: sink });
+ return {
+ chatStream,
+ events: () =>
+ chunks
+ .join('')
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => JSON.parse(line)),
+ };
+};
+
+const OK_COMPLETION = {
+ choices: [
+ {
+ message: { content: 'hi', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
+};
+
+const complete = (
+ provider: MetaProvider,
+ params: Record = {},
+ actor?: Actor,
+) =>
+ withTestActor(
+ () =>
+ provider.complete({
+ model: 'muse-spark-1.2',
+ messages: [{ role: 'user', content: 'hi' }],
+ ...params,
+ } as never),
+ actor,
+ );
+
+beforeEach(() => {
+ createMock.mockReset();
+ openAICtor.mockReset();
+ // Spy on the live MeteringService without replacing the impl, so the
+ // recording side still runs while per-test assertions see the calls.
+ recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+// -- Construction -------------------------------------------------
+
+describe('MetaProvider construction', () => {
+ it('points the OpenAI SDK at the Meta Model API with the configured key', () => {
+ makeProvider();
+ expect(openAICtor).toHaveBeenCalledTimes(1);
+ expect(openAICtor).toHaveBeenCalledWith({
+ apiKey: 'test-key',
+ baseURL: 'https://api.meta.ai/v1',
+ });
+ });
+
+ it('honours a custom apiBaseUrl override', () => {
+ makeProvider({ apiBaseUrl: 'https://staging.meta.test/v1' });
+ expect(openAICtor).toHaveBeenCalledWith({
+ apiKey: 'test-key',
+ baseURL: 'https://staging.meta.test/v1',
+ });
+ });
+});
+
+// -- Model catalog ------------------------------------------------
+
+describe('MetaProvider model catalog', () => {
+ it('returns muse-spark-1.2 as the default', () => {
+ expect(makeProvider().getDefaultModel()).toBe('muse-spark-1.2');
+ });
+
+ it('exposes the static META_MODELS list verbatim from models()', () => {
+ expect(makeProvider().models()).toBe(META_MODELS);
+ });
+
+ it('list() flattens canonical ids and aliases', () => {
+ const names = makeProvider().list();
+ for (const m of META_MODELS) {
+ expect(names).toContain(m.id);
+ for (const a of m.aliases ?? []) {
+ expect(names).toContain(a);
+ }
+ }
+ expect(names).toContain('meta/muse-spark-1.2');
+ });
+
+ it('leaves the contributor tier out of the catalog entirely', () => {
+ // It is the cheapest input rate Meta sells, so any name pointing at it
+ // 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());
+ for (const name of names) {
+ expect(name).not.toContain('contributor');
+ }
+ });
+});
+
+// -- Request shape ------------------------------------------------
+
+describe('MetaProvider.complete request shape', () => {
+ it('sends model and messages without optional knobs', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider());
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.model).toBe('muse-spark-1.2');
+ expect(args.messages).toEqual([{ role: 'user', content: 'hi' }]);
+ for (const key of [
+ 'max_completion_tokens',
+ 'temperature',
+ 'top_p',
+ 'tools',
+ 'tool_choice',
+ 'reasoning_effort',
+ 'prompt_cache_key',
+ 'prompt_cache_retention',
+ 'response_format',
+ 'seed',
+ ]) {
+ expect(key in args).toBe(false);
+ }
+ });
+
+ it('caps output with max_completion_tokens, not the legacy max_tokens', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { max_tokens: 512 });
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.max_completion_tokens).toBe(512);
+ expect('max_tokens' in args).toBe(false);
+ });
+
+ it('forwards temperature, top_p, tools, and tool_choice when supplied', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ const tools = [
+ {
+ type: 'function',
+ function: {
+ name: 'lookup',
+ parameters: {
+ type: 'object',
+ properties: { q: { type: 'string' } },
+ },
+ },
+ },
+ ];
+ await complete(makeProvider(), {
+ temperature: 0.4,
+ top_p: 0.9,
+ tools,
+ tool_choice: 'auto',
+ });
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.temperature).toBe(0.4);
+ expect(args.top_p).toBe(0.9);
+ expect(args.tools).toBe(tools);
+ expect(args.tool_choice).toBe('auto');
+ });
+
+ it('forwards reasoning_effort, including the Meta-only tiers', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { reasoning_effort: 'xhigh' });
+ expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('xhigh');
+ });
+
+ it('falls back to reasoning.effort when reasoning_effort is absent', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { reasoning: { effort: 'low' } });
+ expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low');
+ });
+
+ it('drops reasoning_effort: none, which Muse Spark rejects with a 400', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { reasoning_effort: 'none' });
+ expect('reasoning_effort' in createMock.mock.calls[0]![0]).toBe(false);
+ });
+
+ it("translates the hyphenated in-memory cache retention to Meta's enum", async () => {
+ const provider = makeProvider();
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(provider, {
+ prompt_cache_key: 'cache-abc',
+ prompt_cache_retention: 'in-memory',
+ });
+
+ // Meta rejects the hyphenated spelling outright: "unknown variant
+ // `in-memory`, expected `in_memory` or `24h`".
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.prompt_cache_key).toBe('cache-abc');
+ expect(args.prompt_cache_retention).toBe('in_memory');
+
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(provider, { prompt_cache_retention: '24h' });
+ expect(createMock.mock.calls[1]![0].prompt_cache_retention).toBe('24h');
+ });
+
+ it('forwards Muse-Spark-specific custom params', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), {
+ custom: {
+ seed: 7,
+ response_format: { type: 'json_object' },
+ frequency_penalty: 0.5,
+ presence_penalty: -0.5,
+ },
+ });
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.seed).toBe(7);
+ expect(args.response_format).toEqual({ type: 'json_object' });
+ expect(args.frequency_penalty).toBe(0.5);
+ expect(args.presence_penalty).toBe(-0.5);
+ });
+
+ it('strips Anthropic-style cache_control from messages before sending', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), {
+ messages: [
+ {
+ role: 'user',
+ content: 'hi',
+ cache_control: { type: 'ephemeral' },
+ },
+ ],
+ });
+
+ expect(
+ 'cache_control' in createMock.mock.calls[0]![0].messages[0],
+ ).toBe(false);
+ });
+
+ it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ const userActor: Actor = {
+ user: { id: 42, uuid: 'u42', username: 'alice' },
+ app: { id: 7, uid: 'a'.repeat(80) },
+ };
+
+ await complete(makeProvider(), {}, userActor);
+
+ const identifier = createMock.mock.calls[0]![0].safety_identifier;
+ expect(identifier.startsWith('puter-42-a')).toBe(true);
+ expect(identifier.length).toBe(64);
+ });
+
+ it('prefers an explicit custom.safety_identifier over the actor-derived one', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ const userActor: Actor = { user: { id: 42, uuid: 'u42' } };
+ await complete(
+ makeProvider(),
+ { custom: { safety_identifier: 'caller-supplied' } },
+ userActor,
+ );
+ expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
+ 'caller-supplied',
+ );
+ });
+
+ it('omits safety_identifier for the system actor (no user.id)', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider());
+ expect('safety_identifier' in createMock.mock.calls[0]![0]).toBe(false);
+ });
+
+ it('only sets stream_options.include_usage when streaming', async () => {
+ const provider = makeProvider();
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(provider, { stream: false });
+
+ const [nonStreamArgs] = createMock.mock.calls[0]!;
+ expect(nonStreamArgs.stream).toBe(false);
+ expect('stream_options' in nonStreamArgs).toBe(false);
+
+ createMock.mockReturnValueOnce(asAsyncIterable([]));
+ await complete(provider, { stream: true });
+
+ const [streamArgs] = createMock.mock.calls[1]!;
+ expect(streamArgs.stream).toBe(true);
+ expect(streamArgs.stream_options).toEqual({ include_usage: true });
+ });
+});
+
+// -- Model resolution ---------------------------------------------
+
+describe('MetaProvider model resolution', () => {
+ it('resolves an alias to its canonical id', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { model: 'meta/muse-spark-1.1' });
+
+ expect(createMock.mock.calls[0]![0].model).toBe('muse-spark-1.1');
+ expect(recordSpy).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.anything(),
+ 'meta:muse-spark-1.1',
+ expect.any(Object),
+ );
+ });
+
+ it('falls back to the default model when given an unknown id', async () => {
+ createMock.mockResolvedValueOnce(OK_COMPLETION);
+ await complete(makeProvider(), { model: 'totally-not-a-real-model' });
+
+ expect(createMock.mock.calls[0]![0].model).toBe('muse-spark-1.2');
+ expect(recordSpy).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.anything(),
+ 'meta:muse-spark-1.2',
+ expect.any(Object),
+ );
+ });
+});
+
+// -- Non-stream output --------------------------------------------
+
+describe('MetaProvider.complete non-stream output', () => {
+ it('bills cache reads separately from the remaining prompt tokens', async () => {
+ createMock.mockResolvedValueOnce({
+ choices: [
+ {
+ message: { content: 'hi there', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: {
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ prompt_tokens_details: { cached_tokens: 40 },
+ },
+ });
+
+ const result = await complete(makeProvider());
+
+ expect(result).toMatchObject({
+ message: { content: 'hi there', role: 'assistant' },
+ finish_reason: 'stop',
+ });
+ // Meta reports cache reads inside prompt_tokens; only the uncached
+ // remainder is charged at the input rate.
+ expect((result as { usage: unknown }).usage).toEqual({
+ prompt_tokens: 60,
+ completion_tokens: 50,
+ cached_tokens: 40,
+ });
+
+ const model = META_MODELS.find((m) => m.id === 'muse-spark-1.2')!;
+ expect(recordSpy).toHaveBeenCalledTimes(1);
+ const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
+ expect(usage).toEqual({
+ prompt_tokens: 60,
+ completion_tokens: 50,
+ cached_tokens: 40,
+ });
+ expect(actor).toBe(SYSTEM_ACTOR);
+ expect(prefix).toBe('meta:muse-spark-1.2');
+ expect(overrides!.prompt_tokens).toBeCloseTo(
+ 60 * Number(model.costs.prompt_tokens),
+ 5,
+ );
+ expect(overrides!.completion_tokens).toBeCloseTo(
+ 50 * Number(model.costs.completion_tokens),
+ 5,
+ );
+ expect(overrides!.cached_tokens).toBeCloseTo(
+ 40 * Number(model.costs.cached_tokens),
+ 5,
+ );
+ });
+
+ it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
+ createMock.mockResolvedValueOnce({
+ choices: [
+ {
+ message: { content: 'ok', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: { prompt_tokens: 7, completion_tokens: 3 },
+ });
+
+ await complete(makeProvider());
+
+ const [usage, , , overrides] = recordSpy.mock.calls[0]!;
+ expect(usage).toEqual({
+ prompt_tokens: 7,
+ completion_tokens: 3,
+ cached_tokens: 0,
+ });
+ expect(overrides).toMatchObject({ cached_tokens: 0 });
+ });
+
+ it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
+ createMock.mockResolvedValueOnce({
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content: null,
+ tool_calls: [
+ {
+ id: 'call_1',
+ type: 'function',
+ function: {
+ name: 'lookup',
+ arguments: '{"q":"puter"}',
+ },
+ },
+ ],
+ },
+ finish_reason: 'tool_calls',
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
+ });
+
+ const result = (await complete(makeProvider(), {
+ tools: [
+ {
+ type: 'function',
+ function: { name: 'lookup', parameters: {} },
+ },
+ ],
+ })) as { message: { tool_calls?: unknown[] }; finish_reason: string };
+
+ expect(result.finish_reason).toBe('tool_calls');
+ expect(result.message.tool_calls).toEqual([
+ {
+ id: 'call_1',
+ type: 'function',
+ function: { name: 'lookup', arguments: '{"q":"puter"}' },
+ },
+ ]);
+ });
+
+ it('leaves reasoning token counts out of the metered usage', async () => {
+ createMock.mockResolvedValueOnce({
+ choices: [
+ {
+ message: { content: 'done', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: {
+ prompt_tokens: 8,
+ completion_tokens: 100,
+ completion_tokens_details: { reasoning_tokens: 90 },
+ },
+ });
+
+ await complete(makeProvider());
+
+ // Muse Spark always reasons, and counts those tokens inside
+ // `completion_tokens` — billing them again would double-charge.
+ const [usage] = recordSpy.mock.calls[0]!;
+ expect(usage).toEqual({
+ prompt_tokens: 8,
+ completion_tokens: 100,
+ cached_tokens: 0,
+ });
+ });
+});
+
+// -- Streaming ----------------------------------------------------
+
+describe('MetaProvider.complete streaming', () => {
+ it('streams text deltas and meters the final usage chunk', async () => {
+ createMock.mockReturnValueOnce(
+ asAsyncIterable([
+ { choices: [{ delta: { content: 'hel' } }] },
+ { choices: [{ delta: { content: 'lo' } }] },
+ {
+ choices: [{ delta: {} }],
+ usage: {
+ prompt_tokens: 10,
+ completion_tokens: 2,
+ prompt_tokens_details: { cached_tokens: 4 },
+ },
+ },
+ ]),
+ );
+
+ const result = await complete(makeProvider(), { stream: true });
+ expect((result as { stream: boolean }).stream).toBe(true);
+
+ const harness = makeCapturingChatStream();
+ await (
+ result as {
+ init_chat_stream: (p: { chatStream: unknown }) => Promise;
+ }
+ ).init_chat_stream({ chatStream: harness.chatStream });
+
+ const events = harness.events();
+ expect(
+ events.filter((e) => e.type === 'text').map((e) => e.text),
+ ).toEqual(['hel', 'lo']);
+ expect(events.find((e) => e.type === 'usage')?.usage).toEqual({
+ prompt_tokens: 6,
+ completion_tokens: 2,
+ cached_tokens: 4,
+ });
+
+ const model = META_MODELS.find((m) => m.id === 'muse-spark-1.2')!;
+ expect(recordSpy).toHaveBeenCalledTimes(1);
+ const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
+ expect(prefix).toBe('meta:muse-spark-1.2');
+ expect(overrides!.prompt_tokens).toBeCloseTo(
+ 6 * Number(model.costs.prompt_tokens),
+ 5,
+ );
+ expect(overrides!.cached_tokens).toBeCloseTo(
+ 4 * Number(model.costs.cached_tokens),
+ 5,
+ );
+ });
+
+ it('builds a tool_use event from streamed function-call deltas', async () => {
+ createMock.mockReturnValueOnce(
+ asAsyncIterable([
+ {
+ choices: [
+ {
+ delta: {
+ tool_calls: [
+ {
+ index: 0,
+ id: 'call_1',
+ function: {
+ name: 'lookup',
+ arguments: '{"q":',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ {
+ choices: [
+ {
+ delta: {
+ tool_calls: [
+ {
+ index: 0,
+ function: { arguments: '"puter"}' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ {
+ choices: [{ delta: {} }],
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
+ },
+ ]),
+ );
+
+ const result = await complete(makeProvider(), {
+ stream: true,
+ tools: [
+ {
+ type: 'function',
+ function: { name: 'lookup', parameters: {} },
+ },
+ ],
+ });
+
+ const harness = makeCapturingChatStream();
+ await (
+ result as {
+ init_chat_stream: (p: { chatStream: unknown }) => Promise;
+ }
+ ).init_chat_stream({ chatStream: harness.chatStream });
+
+ const toolEvent = harness.events().find((e) => e.type === 'tool_use');
+ expect(toolEvent).toBeDefined();
+ expect(toolEvent?.id).toBe('call_1');
+ expect(toolEvent?.name).toBe('lookup');
+ expect(toolEvent?.input).toEqual({ q: 'puter' });
+ });
+});
+
+// -- Error mapping ------------------------------------------------
+
+describe('MetaProvider.complete error mapping', () => {
+ it('rethrows errors raised by the OpenAI client unchanged', async () => {
+ const apiError = new Error('Model API exploded');
+ createMock.mockRejectedValueOnce(apiError);
+
+ await expect(complete(makeProvider())).rejects.toBe(apiError);
+ // A failed call must not be metered.
+ expect(recordSpy).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing messages payload with a 400', async () => {
+ await expect(
+ withTestActor(() =>
+ makeProvider().complete({
+ model: 'muse-spark-1.2',
+ } as never),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ expect(createMock).not.toHaveBeenCalled();
+ });
+});
+
+// -- Moderation ---------------------------------------------------
+
+describe('MetaProvider.checkModeration', () => {
+ it('throws — the Meta provider does not implement moderation', () => {
+ expect(() => makeProvider().checkModeration('anything')).toThrow(
+ /not implemented/i,
+ );
+ });
+});
diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts
new file mode 100644
index 000000000..10639311d
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts
@@ -0,0 +1,260 @@
+/*
+ * 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 .
+ */
+
+import { OpenAI } from 'openai';
+import { ChatCompletionCreateParams } from 'openai/resources/index.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 type { IChatProvider, ICompleteArguments } from '../../types.js';
+import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
+import { buildCostsOverride } from '../../utils/pricing.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';
+
+// `safety_identifier` is capped at 64 characters by the Model API.
+const SAFETY_IDENTIFIER_MAX_LENGTH = 64;
+
+type MetaConfig = {
+ apiBaseUrl?: string;
+ apiKey: string;
+};
+
+/**
+ * Chat Completions params Muse Spark accepts that Puter has no first-class
+ * argument for. Passed through `custom`.
+ */
+type MetaCustomParams = {
+ frequency_penalty?: number;
+ presence_penalty?: number;
+ response_format?: unknown;
+ safety_identifier?: string;
+ seed?: number;
+};
+
+const asRecord = (value: unknown): Record =>
+ value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+
+/**
+ * Meta's Model API — the Muse Spark family, served OpenAI-compatible from
+ * `https://api.meta.ai/v1`.
+ *
+ * Only the Chat Completions protocol is used here; Meta also fronts the same
+ * models behind Responses- and Anthropic-Messages-shaped endpoints.
+ */
+export class MetaProvider implements IChatProvider {
+ #openai: OpenAI;
+
+ #meteringService: MeteringService;
+
+ #stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
+
+ #fsService: FSService;
+
+ constructor(
+ meteringService: MeteringService,
+ stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
+ fsService: FSService,
+ config: MetaConfig,
+ ) {
+ this.#openai = new OpenAI({
+ apiKey: config.apiKey,
+ baseURL: config.apiBaseUrl ?? DEFAULT_API_BASE_URL,
+ });
+ this.#meteringService = meteringService;
+ this.#stores = stores;
+ this.#fsService = fsService;
+ }
+
+ getDefaultModel() {
+ return MUSE_SPARK_DEFAULT_MODEL;
+ }
+
+ models() {
+ return META_MODELS;
+ }
+
+ list() {
+ const modelIds: string[] = [];
+ for (const model of this.models()) {
+ modelIds.push(model.id);
+ if (model.aliases) {
+ modelIds.push(...model.aliases);
+ }
+ }
+ return modelIds;
+ }
+
+ async complete(
+ params: ICompleteArguments,
+ ): ReturnType {
+ const {
+ custom,
+ max_tokens,
+ prompt_cache_key,
+ prompt_cache_retention,
+ reasoning,
+ reasoning_effort,
+ stream,
+ temperature,
+ tool_choice,
+ tools,
+ top_p,
+ } = params;
+ let { messages, model } = params;
+ if (!Array.isArray(messages)) {
+ throw new HttpError(400, '`messages` must be an array', {
+ legacyCode: 'bad_request',
+ });
+ }
+
+ const actor = Context.get('actor');
+ const availableModels = this.models();
+ const modelUsed =
+ availableModels.find((m) =>
+ [m.id, ...(m.aliases || [])].includes(model),
+ ) || availableModels.find((m) => m.id === this.getDefaultModel())!;
+
+ // Muse Spark reads images, video, PDFs and audio, but Chat Completions
+ // takes them inline only — resolve `puter_path` parts to data URLs.
+ await processPuterPathUploads(
+ messages,
+ this.#stores,
+ this.#fsService,
+ actor,
+ );
+
+ messages = await OpenAIUtil.process_input_messages(messages);
+ messages = messages.map((message) => {
+ // Anthropic-shaped cache hints don't belong on this wire; Meta
+ // caches via `prompt_cache_key` / `prompt_cache_retention`.
+ delete message.cache_control;
+ return message;
+ });
+
+ const customParams = asRecord(custom) as MetaCustomParams;
+
+ // Reasoning is always on for Muse Spark — `reasoning_effort: 'none'`
+ // is a 400 — so a request to switch it off is dropped, not forwarded.
+ const requestedEffort = (reasoning_effort ?? reasoning?.effort) as
+ string | undefined;
+ const effort =
+ requestedEffort && requestedEffort !== 'none'
+ ? requestedEffort
+ : undefined;
+
+ // Puter spells the in-memory retention with a hyphen; Meta's enum
+ // uses an underscore.
+ const cacheRetention =
+ prompt_cache_retention === 'in-memory'
+ ? 'in_memory'
+ : prompt_cache_retention;
+
+ const safetyIdentifier =
+ customParams.safety_identifier ??
+ (actor?.user?.id
+ ? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
+ 0,
+ SAFETY_IDENTIFIER_MAX_LENGTH,
+ )
+ : undefined);
+
+ const completionParams = {
+ messages,
+ model: modelUsed.id,
+ ...(tools ? { tools } : {}),
+ ...(tool_choice !== undefined ? { tool_choice } : {}),
+ // Reasoning tokens come out of this same budget, so a tight cap
+ // returns `content: null` with `finish_reason: 'length'`.
+ ...(max_tokens !== undefined
+ ? { max_completion_tokens: max_tokens }
+ : {}),
+ ...(temperature !== undefined ? { temperature } : {}),
+ ...(top_p !== undefined ? { top_p } : {}),
+ ...(effort ? { reasoning_effort: effort } : {}),
+ ...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
+ ...(cacheRetention !== undefined
+ ? { prompt_cache_retention: cacheRetention }
+ : {}),
+ ...(safetyIdentifier
+ ? { safety_identifier: safetyIdentifier }
+ : {}),
+ ...(customParams.response_format
+ ? { response_format: customParams.response_format }
+ : {}),
+ ...(customParams.frequency_penalty !== undefined
+ ? { frequency_penalty: customParams.frequency_penalty }
+ : {}),
+ ...(customParams.presence_penalty !== undefined
+ ? { presence_penalty: customParams.presence_penalty }
+ : {}),
+ ...(customParams.seed !== undefined
+ ? { seed: customParams.seed }
+ : {}),
+ stream: !!stream,
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
+ } as ChatCompletionCreateParams;
+
+ const completion =
+ await this.#openai.chat.completions.create(completionParams);
+
+ return OpenAIUtil.handle_completion_output({
+ usage_calculator: ({ usage }) => {
+ const cachedTokens =
+ usage?.prompt_tokens_details?.cached_tokens ?? 0;
+ // Meta reports cache reads as a subset of `prompt_tokens`, so
+ // the remainder is what the input rate applies to. Reasoning
+ // tokens (`completion_tokens_details.reasoning_tokens`) are
+ // likewise already inside `completion_tokens` — metering them
+ // again would bill the same tokens twice.
+ const trackedUsage = {
+ prompt_tokens: (usage?.prompt_tokens ?? 0) - cachedTokens,
+ completion_tokens: usage?.completion_tokens ?? 0,
+ cached_tokens: cachedTokens,
+ };
+ const costsOverride = buildCostsOverride(
+ trackedUsage,
+ modelUsed,
+ );
+ this.#meteringService.utilRecordUsageObject(
+ trackedUsage,
+ actor!,
+ `meta:${modelUsed.id}`,
+ costsOverride,
+ );
+ return trackedUsage;
+ },
+ stream,
+ completion,
+ });
+ }
+
+ checkModeration(
+ _text: string,
+ ): ReturnType {
+ throw new Error('Method not implemented.');
+ }
+}
diff --git a/src/backend/drivers/ai-chat/providers/meta/models.ts b/src/backend/drivers/ai-chat/providers/meta/models.ts
new file mode 100644
index 000000000..3cd40c450
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/meta/models.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 .
+ */
+
+import type { IChatModel } from '../../types.js';
+import { usdPerMToken } from '../../utils/pricing.js';
+
+export const MUSE_SPARK_DEFAULT_MODEL = 'muse-spark-1.2';
+
+const museSpark = (model: {
+ id: string;
+ name: string;
+ context: number;
+ maxTokens: number;
+ releaseDate: string;
+ inputModalities: string[];
+ costs: IChatModel['costs'];
+}): IChatModel => ({
+ puterId: `meta:meta/${model.id}`,
+ id: model.id,
+ name: model.name,
+ aliases: [`meta/${model.id}`],
+ modalities: { input: model.inputModalities, output: ['text'] },
+ open_weights: false,
+ tool_call: true,
+ release_date: model.releaseDate,
+ context: model.context,
+ max_tokens: model.maxTokens,
+ costs_currency: 'usd-cents',
+ input_cost_key: 'prompt_tokens',
+ output_cost_key: 'completion_tokens',
+ costs: model.costs,
+});
+
+// Hardcoded from https://models.dev/api.json and
+// https://dev.meta.ai/docs/pricing-rate-limits.
+//
+// Meta also sells `muse-spark-1.2-contributor`: the same checkpoint at a tenth
+// of the price in exchange for permission to train on the prompts and
+// completions it serves. It stays out of this catalog — it needs a separate
+// enrolment (a standard-tier key gets `model_not_found`), and its input rate
+// would make it the cheapest route in the bucket, so listing it would quietly
+// route callers' prompts into Meta's training set.
+export const META_MODELS: IChatModel[] = [
+ museSpark({
+ id: 'muse-spark-1.2',
+ name: 'Muse Spark 1.2',
+ context: 1_048_576,
+ maxTokens: 131_072,
+ releaseDate: '2026-08-05',
+ inputModalities: ['text', 'image', 'video', 'audio', 'pdf'],
+ costs: usdPerMToken(1.25, 4.25, 0.15),
+ }),
+ museSpark({
+ id: 'muse-spark-1.1',
+ name: 'Muse Spark 1.1',
+ context: 1_048_576,
+ maxTokens: 32_000,
+ releaseDate: '2026-04-08',
+ inputModalities: ['text', 'image', 'video', 'pdf'],
+ costs: usdPerMToken(1.25, 4.25, 0.15),
+ }),
+];
diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md
index f46b23768..288bcb61f 100755
--- a/src/docs/src/AI/chat.md
+++ b/src/docs/src/AI/chat.md
@@ -33,7 +33,7 @@ An object containing the following properties:
- `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 specific model's temperature is used.
- `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 only.
+- `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 and Meta's Muse Spark models only; Muse Spark always reasons, so `none` is ignored for it.
- `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.
- `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).
@@ -112,7 +112,7 @@ In case of an error, the `Promise` will reject with an error message.
## Vendors
-We use different vendors for different models and try to use the best vendor available at the time of the request. Vendors currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them.
+We use different vendors for different models and try to use the best vendor available at the time of the request. Vendors currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, Meta, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them.
## Function Calling