fix(ai): make chat fallback reach streamed Claude calls and rank Azure explicitly (#3743)
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

* fix(ai): make chat fallback reach streamed Claude calls and rank Azure explicitly

- ClaudeProvider opens the upstream stream and awaits its connection
  before returning the populator, so an overloaded or rate-limited route
  throws from complete() and reaches the driver's fallback loop instead
  of surfacing as an error frame on a 200
- the OpenAI-compatible chat and completions routes only pin a provider
  when the caller sent one, so they get the same preferred healthy route
  puter.js callers do and unhealthy-route skipping applies to their
  first attempt
- Azure is ranked ahead of the vendors it fronts by an explicit tier in
  modelRouting rather than a price tie plus registration order
- drop Together's synthetic always-failing model-fallback-test-1 entry
- test that a 4xx leaves a route in rotation while a 503 marks it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ai): surface swallowed Claude stream errors, keep compat-route defaults

Review follow-ups on the fallback work.

- The pre-created event iterator only receives an error if a reader is
  already waiting on it, so a failure landing between the connect and the
  populator's first pull ended the stream cleanly — truncated content
  billed and reported as a success. Rethrow when the stream is errored.
- A refused stream deleted its Anthropic uploads but left the caller's
  message parts pointing at those file ids, so the fallback route was
  handed handles it cannot resolve. processPuterPathUploads now returns a
  restore() that both failure paths call.
- The OpenAI-compat routes keep pinning OpenAI when the caller sends no
  model at all, so the default model stays put instead of moving to
  Azure's.
- Say why /openai/v1/responses and /anthropic/v1/messages stay pinned:
  each translates one provider's native shape by hand.
- PREFERRED_PROVIDERS is unexported and its doc now states the rank is
  unconditional; the duplicated hidden-model list is one constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ai): undo the puter_path rewrite by field instead of snapshotting the part

Copying the content part kept whatever the caller sent on it — a large
inline `source` or `text` alongside `puter_path` — reachable until the
request ended, where overwriting the field used to make it garbage right
away. The only fields this function writes are `type`/`source` on success
and `type`/`text` on failure, and the fallback uploader keys off
`puter_path` alone, so restore undoes those three by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
404oops
2026-09-04 11:10:05 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 2966cee6b0
commit 7248951410
14 changed files with 522 additions and 99 deletions
@@ -1478,7 +1478,7 @@ describe('PuterAIController model listing edges', () => {
vi.spyOn(server.drivers.aiVideo, 'models').mockResolvedValueOnce([
{ id: 'veo-test' },
{ id: 'fake' },
{ id: 'model-fallback-test-1' },
{ id: 'costly' },
] as never);
const { res, captured } = makeRes();
@@ -360,7 +360,8 @@ describe('PuterAIController.openaiChatCompletions', () => {
res,
);
// Driver was given the user's messages and the default chat provider.
// Driver was given the user's messages and no provider pin, so it is
// free to pick the preferred healthy route for the model.
expect(completeSpy).toHaveBeenCalledTimes(1);
const completeArgs = completeSpy.mock.calls[0]![0];
expect(completeArgs.model).toBe('gpt-test');
@@ -368,7 +369,7 @@ describe('PuterAIController.openaiChatCompletions', () => {
{ role: 'user', content: 'hi' },
]);
expect(completeArgs.stream).toBe(false);
expect(completeArgs.provider).toBe('openai-completion');
expect(completeArgs.provider).toBeUndefined();
// Wire routes translate shapes themselves; the driver is pinned
// provider-native so the release-date cutoff can't change them.
expect(completeArgs.normalize).toBe(false);
@@ -395,6 +396,53 @@ describe('PuterAIController.openaiChatCompletions', () => {
expect((body.id as string).startsWith('chatcmpl-')).toBe(true);
});
it('forwards an explicit provider as a pin instead of choosing one itself', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
usage: { prompt_tokens: 4, completion_tokens: 2 },
});
const { res } = makeRes();
await controller.openaiChatCompletions(
makeReq({
body: {
model: 'gpt-test',
provider: 'openai-completion',
messages: [{ role: 'user', content: 'hi' }],
},
actor: makeUserActor(),
}),
res,
);
const completeArgs = completeSpy.mock.calls[0]![0];
expect(completeArgs.provider).toBe('openai-completion');
});
it('still pins OpenAI when the caller names no model, so the default model does not move', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
usage: { prompt_tokens: 4, completion_tokens: 2 },
});
const { res } = makeRes();
await controller.openaiChatCompletions(
makeReq({
body: { messages: [{ role: 'user', content: 'hi' }] },
actor: makeUserActor(),
}),
res,
);
// With no model there is no route to prefer — unpinned, this would
// take its default model from whichever provider the driver picks.
expect(completeSpy.mock.calls[0]![0].provider).toBe(
'openai-completion',
);
});
it('streams chat completion deltas as SSE chunks ending with [DONE]', async () => {
stubChatComplete({
// The controller's expectStream() checks the DriverStreamResult
@@ -548,6 +596,59 @@ describe('PuterAIController.openaiCompletions', () => {
});
expect((body.id as string).startsWith('cmpl-')).toBe(true);
});
it('leaves provider unset for a named model so the driver can route it', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'response' },
finish_reason: 'stop',
});
const { res } = makeRes();
await controller.openaiCompletions(
makeReq({
body: { model: 'gpt-test', prompt: 'hello' },
actor: makeUserActor(),
}),
res,
);
expect(completeSpy.mock.calls[0]![0].provider).toBeUndefined();
});
it('forwards an explicit provider as a pin', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'response' },
finish_reason: 'stop',
});
const { res } = makeRes();
await controller.openaiCompletions(
makeReq({
body: {
model: 'gpt-test',
provider: 'openai-completion',
prompt: 'hello',
},
actor: makeUserActor(),
}),
res,
);
expect(completeSpy.mock.calls[0]![0].provider).toBe(
'openai-completion',
);
});
it('still pins OpenAI when the caller names no model', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'response' },
finish_reason: 'stop',
});
const { res } = makeRes();
await controller.openaiCompletions(
makeReq({ body: { prompt: 'hello' }, actor: makeUserActor() }),
res,
);
expect(completeSpy.mock.calls[0]![0].provider).toBe(
'openai-completion',
);
});
});
// ── /openai/v1/responses ────────────────────────────────────────────
@@ -286,9 +286,8 @@ export class PuterAIController extends PuterController {
legacyCode: 'internal_error',
});
const models = await driver.list();
const HIDDEN = ['costly', 'fake', 'abuse', 'model-fallback-test-1'];
res.json({
models: models?.filter((m) => !HIDDEN.includes(m)),
models: models?.filter((m) => !HIDDEN_MODELS.includes(m)),
});
};
}
@@ -301,9 +300,8 @@ export class PuterAIController extends PuterController {
legacyCode: 'internal_error',
});
const models = await driver.models();
const HIDDEN = ['costly', 'fake', 'abuse', 'model-fallback-test-1'];
res.json({
models: models?.filter((m) => !HIDDEN.includes(m.id)),
models: models?.filter((m) => !HIDDEN_MODELS.includes(m.id)),
});
};
}
@@ -341,9 +339,7 @@ export class PuterAIController extends PuterController {
? { temperature: Number(body.temperature) }
: {}),
...(finiteMaxTokens(body.max_tokens) ?? {}),
...(body.provider
? { provider: toStringOrEmpty(body.provider) }
: { provider: DEFAULTS.openaiChat }),
...openaiCompatProvider(body),
};
const result = await this.#driver().complete(completeArgs);
@@ -499,9 +495,7 @@ export class PuterAIController extends PuterController {
? { temperature: Number(body.temperature) }
: {}),
...(finiteMaxTokens(body.max_tokens) ?? {}),
...(body.provider
? { provider: toStringOrEmpty(body.provider) }
: { provider: DEFAULTS.openaiCompletion }),
...openaiCompatProvider(body),
};
const completionId = `cmpl-${randomId()}`;
@@ -589,8 +583,7 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
)?.content,
),
index: 0,
@@ -612,6 +605,9 @@ export class PuterAIController extends PuterController {
const body = asRecord(req.body);
const stream = !!body.stream;
// Pinned, unlike the chat/completions routes: the translators below
// read one provider's native Responses shape, so the preferred-route
// and unhealthy-route logic cannot be allowed to swap it out.
const providerName =
toStringOrEmpty(body.provider) || DEFAULTS.openaiResponses;
if (providerName !== DEFAULTS.openaiResponses) {
@@ -991,6 +987,9 @@ export class PuterAIController extends PuterController {
body.compaction as ICompleteArguments['compaction'],
}
: {}),
// Pinned for the same reason as /openai/v1/responses: this route
// translates Anthropic's native shape and cannot take whatever
// the preferred healthy route happens to return.
...(body.provider
? { provider: toStringOrEmpty(body.provider) }
: { provider: DEFAULTS.anthropic }),
@@ -1201,11 +1200,28 @@ export class PuterAIController extends PuterController {
const DEFAULTS = {
openaiChat: 'openai-completion',
openaiCompletion: 'openai-completion',
openaiResponses: 'openai-responses',
anthropic: 'claude',
} as const;
/** Test-only chat models, kept out of the public listings. */
const HIDDEN_MODELS = ['costly', 'fake', 'abuse'];
/**
* How the OpenAI-compat routes pin a provider. An explicit one is the caller's
* choice; a named model is left unpinned so the driver picks the preferred
* healthy route, as it does for puter.js callers. A request with no model has
* no route to prefer, so it stays pinned and keeps taking its default model
* from OpenAI rather than silently switching to another vendor's.
*/
const openaiCompatProvider = (
body: Record<string, unknown>,
): { provider?: string } => {
if (body.provider) return { provider: toStringOrEmpty(body.provider) };
if (toStringOrEmpty(body.model)) return {};
return { provider: DEFAULTS.openaiChat };
};
const randomId = (): string => crypto.randomUUID().replace(/-/g, '');
const generateId = (prefix: string): string => `${prefix}_${randomId()}`;
@@ -47,6 +47,7 @@ import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import {
clearUnhealthyRoutes,
isRouteUnhealthy,
markRouteUnhealthy,
} from './utils/providerHealth.js';
@@ -418,6 +419,31 @@ describe('ChatCompletionDriver unhealthy-route skipping', () => {
expect(attempts[0]).toMatchObject({ provider: 'infron' });
});
it('marks a route only for failures that indict the route, not the request', async () => {
const complete = () =>
withTestActor(() =>
driver.complete({
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
).catch(() => undefined);
// A 400 is the upstream judging this prompt; the next caller's may
// be fine, so the route stays in rotation.
createMock.mockRejectedValue(
Object.assign(new Error('invalid request'), { status: 400 }),
);
await complete();
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(false);
// A 503 says the route itself is down and is remembered.
createMock.mockRejectedValue(
Object.assign(new Error('service unavailable'), { status: 503 }),
);
await complete();
expect(isRouteUnhealthy('deepseek', 'deepseek-v4-pro')).toBe(true);
});
});
// A transport timeout carries no status, so the classifier used to lump a
@@ -202,9 +202,9 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('falls back to the provider default model when neither model nor provider is given (claude is the hard-coded default provider)', async () => {
// Without `claude` in providers config, the driver tries
// `claude` as the default provider but it isn't registered, so
it('falls back to the provider default model when neither model nor provider is given (azure-openai is the hard-coded default provider)', async () => {
// Without `azure-openai` in providers config, the driver tries
// `azure-openai` as the default provider but it isn't registered, so
// `args.model` stays undefined and `#resolveModel` returns null
// — surfaces as 400.
await expect(
@@ -1114,10 +1114,9 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
// Azure AI Foundry (OpenAI + xAI Grok). Registered before the regular
// OpenAI/xAI providers so that since its costs mirror theirs but
// Azure is preferred for us, it takes precedence in the per-model
// bucket
// Azure AI Foundry (OpenAI + xAI Grok). Its costs mirror the vendors'
// and it is preferred for us; `PREFERRED_PROVIDERS` in modelRouting
// puts it ahead of them in the per-model bucket.
const azureOpenai = providers['azure-openai'];
const azureOpenaiKey = readKey(azureOpenai);
const azureOpenaiURL = azureOpenai?.apiURL as string | undefined;
@@ -44,24 +44,35 @@ import {
type MockInstance,
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
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 { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { FILES_API_BETA } from './fileUpload.js';
import { CLAUDE_MODELS } from './models.js';
import { ClaudeProvider } from './ClaudeProvider.js';
// ── Anthropic SDK mock ──────────────────────────────────────────────
const { messagesCreateMock, messagesStreamMock, anthropicCtor } = vi.hoisted(
() => ({
messagesCreateMock: vi.fn(),
messagesStreamMock: vi.fn(),
anthropicCtor: vi.fn(),
}),
);
const {
messagesCreateMock,
messagesStreamMock,
anthropicCtor,
filesUploadMock,
filesDeleteMock,
} = vi.hoisted(() => ({
messagesCreateMock: vi.fn(),
messagesStreamMock: vi.fn(),
anthropicCtor: vi.fn(),
filesUploadMock: vi.fn(),
filesDeleteMock: vi.fn(),
}));
vi.mock('@anthropic-ai/sdk', () => {
const Anthropic = vi.fn().mockImplementation(function (
@@ -76,14 +87,20 @@ vi.mock('@anthropic-ai/sdk', () => {
// Beta files surface — only consulted when puter_path uploads run, so
// tests that exercise text-only paths never hit these stubs.
this.beta = {
files: { delete: vi.fn() },
files: { upload: filesUploadMock, delete: filesDeleteMock },
messages: {
create: messagesCreateMock,
stream: messagesStreamMock,
},
};
});
return { default: Anthropic };
return {
default: Anthropic,
toFile: async (data: unknown, filename: string) => ({
data,
filename,
}),
};
});
// ── Test harness ────────────────────────────────────────────────────
@@ -122,11 +139,13 @@ const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
const makeStreamLike = (events: unknown[], finalUsage?: unknown) => {
// Anthropic's `messages.stream(...)` returns an object that is itself
// both an async iterable (the events) AND has a `.finalMessage()`
// promise. The provider awaits both.
// an async iterable (the events), has a `.withResponse()` promise that
// settles once the upstream accepts the request, AND has a
// `.finalMessage()` promise. The provider awaits all three.
const iter = asAsyncIterable(events);
return {
[Symbol.asyncIterator]: iter[Symbol.asyncIterator].bind(iter),
withResponse: () => Promise.resolve({}),
finalMessage: () =>
Promise.resolve({
usage: finalUsage ?? { input_tokens: 0, output_tokens: 0 },
@@ -134,6 +153,47 @@ const makeStreamLike = (events: unknown[], finalUsage?: unknown) => {
};
};
/**
* A user with one real FS entry, for the `puter_path` upload branch. Only the
* Files API calls are stubbed; the read goes through the wired FSService.
*/
const makeUserWithFile = async () => {
const username = `clsp-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const user = (await server.stores.user.getById(created.id))!;
const actor = {
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
email_confirmed: true,
} as Actor['user'],
};
const path = `/${username}/Documents/pic.png`;
await withTestActor(
() =>
server.services.fs.write(user.id, {
fileMetadata: { path, size: 4, contentType: 'image/png' },
fileContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
}),
actor,
);
return { actor, path };
};
const makeCapturingChatStream = () => {
const chunks: string[] = [];
const sink = new Writable({
@@ -158,6 +218,8 @@ beforeEach(() => {
messagesCreateMock.mockReset();
messagesStreamMock.mockReset();
anthropicCtor.mockReset();
filesUploadMock.mockReset();
filesDeleteMock.mockReset();
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
@@ -881,6 +943,111 @@ describe('ClaudeProvider.complete non-stream output', () => {
// ── Streaming deltas ────────────────────────────────────────────────
describe('ClaudeProvider.complete streaming', () => {
it('rejects from complete() when the upstream refuses the stream, so the driver can fall back', async () => {
const { provider } = makeProvider();
const refused = Object.assign(new Error('Overloaded'), {
status: 529,
});
messagesStreamMock.mockReturnValueOnce({
...makeStreamLike([]),
withResponse: () => Promise.reject(refused),
});
// Thrown here, before a populator exists — a populator that failed
// later would already have a 200 on the wire.
await expect(
withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
),
).rejects.toBe(refused);
});
it('deletes the uploaded files and hands back the puter_path when the stream is refused', async () => {
const { provider } = makeProvider();
const { actor, path } = await makeUserWithFile();
filesUploadMock.mockResolvedValue({ id: 'file_stream_1' });
const refused = Object.assign(new Error('Overloaded'), {
status: 529,
});
messagesStreamMock.mockReturnValueOnce({
...makeStreamLike([]),
withResponse: () => Promise.reject(refused),
});
const part: Record<string, unknown> = { puter_path: path };
await expect(
withTestActor(
() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: [part] }],
stream: true,
}),
actor,
),
).rejects.toBe(refused);
expect(filesUploadMock).toHaveBeenCalledTimes(1);
expect(filesDeleteMock).toHaveBeenCalledWith('file_stream_1', {
betas: [FILES_API_BETA],
});
// The driver reuses this object on the fallback route, which has no
// way to resolve a file we just deleted from our own account.
expect(part).toEqual({ puter_path: path });
});
it('surfaces a failure the event iterator swallowed instead of ending the stream clean', async () => {
const { provider } = makeProvider();
const dropped = Object.assign(new Error('Overloaded'), {
status: 529,
});
// The SDK hands an error only to a reader already waiting on it; one
// that lands earlier leaves the iterator reporting a plain end of
// stream, so `errored` is the only thing left to go on.
messagesStreamMock.mockReturnValueOnce({
...makeStreamLike([
{ type: 'message_start' },
{
type: 'content_block_start',
content_block: { type: 'text' },
},
{
type: 'content_block_delta',
delta: { type: 'text_delta', text: 'half an ans' },
},
]),
errored: true,
finalMessage: () => Promise.reject(dropped),
});
const result = await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await expect(
(
result as {
init_chat_stream: (p: {
chatStream: unknown;
}) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream }),
).rejects.toBe(dropped);
// A truncated response reported as a success would also have been
// billed for the tokens it did produce.
expect(recordSpy).not.toHaveBeenCalled();
});
it('streams text_delta events as text and meters usage from message_delta + finalMessage', async () => {
const { provider } = makeProvider();
messagesStreamMock.mockReturnValueOnce(
@@ -329,13 +329,14 @@ export class ClaudeProvider implements IChatProvider {
// Upload any `puter_path` parts to Anthropic's Files API and rewrite
// them in-place to reference the returned `file_id`. Must happen
// before sdkParams snapshots `messages`.
const { fileIds: uploadedFileIds } = await processPuterPathUploads(
this.anthropic,
messages,
this.#stores,
this.#fsService,
actor,
);
const { fileIds: uploadedFileIds, restore: restoreUploads } =
await processPuterPathUploads(
this.anthropic,
messages,
this.#stores,
this.#fsService,
actor,
);
const usesBetaFiles = uploadedFileIds.length > 0;
// The compaction beta is needed both to *request* compaction
// (contextManagement) and to *accept a round-tripped* compaction block
@@ -403,14 +404,28 @@ export class ClaudeProvider implements IChatProvider {
};
if (stream) {
const completion = usesBeta
? this.anthropic.beta.messages.stream(sdkParams)
: this.anthropic.messages.stream(sdkParams);
// Subscribed before the request is awaited: the SDK only queues
// events for iterators that already exist.
const events = completion[Symbol.asyncIterator]();
// The driver's fallback loop only sees what this method throws, so
// the upstream has to accept the request before a populator exists.
try {
await completion.withResponse();
} catch (e) {
await cleanupUploads();
restoreUploads();
throw e;
}
const init_chat_stream = async ({
chatStream,
}: {
chatStream: AIChatStream;
}) => {
const completion = usesBeta
? this.anthropic.beta.messages.stream(sdkParams)
: this.anthropic.messages.stream(sdkParams);
const usageSum: Record<string, number> = {};
let message, contentBlock;
@@ -425,7 +440,9 @@ export class ClaudeProvider implements IChatProvider {
buffer: string;
} | null = null;
let emittedCompaction = false;
for await (const event of completion) {
for await (const event of {
[Symbol.asyncIterator]: () => events,
}) {
if (event.type === 'message_delta') {
const meteredData = this.#usageFormatterUtil(
(event?.usage ?? {}) as Usage | BetaUsage,
@@ -542,6 +559,11 @@ export class ClaudeProvider implements IChatProvider {
// signature_delta — ignored
}
}
// The SDK only rejects event readers that were already
// waiting, so a failure that landed before this loop started
// pulling ends it silently rather than throwing.
if (completion.errored) await completion.finalMessage();
const finalMessage = await completion
.finalMessage()
.catch((): null => null);
@@ -645,6 +667,9 @@ export class ClaudeProvider implements IChatProvider {
}
: {}),
};
} catch (e) {
restoreUploads();
throw e;
} finally {
await cleanupUploads();
}
@@ -130,7 +130,7 @@ describe('claude processPuterPathUploads uploading', () => {
);
expect(upload).not.toHaveBeenCalled();
expect(result).toEqual({ fileIds: [] });
expect(result.fileIds).toEqual([]);
});
it('uploads an image and rewrites the part to an image block referencing the file_id', async () => {
@@ -232,6 +232,56 @@ describe('claude processPuterPathUploads uploading', () => {
// -- Rejection paths -------------------------------------------------
describe('claude processPuterPathUploads restoring', () => {
it('puts a rewritten part back to the puter_path it arrived as', async () => {
const { actor, userId } = await makeUser();
const { client } = makeAnthropicStub();
const username = actor.user!.username!;
const path = `/${username}/Documents/pic.png`;
await writeFile(
actor,
userId,
path,
Buffer.from([0x89, 0x50, 0x4e, 0x47]),
'image/png',
);
const part: Record<string, unknown> = { puter_path: path };
const result = await processPuterPathUploads(
client,
[{ content: [part] }],
server.stores,
server.services.fs,
actor,
);
expect(part.source).toEqual({ type: 'file', file_id: 'file_1' });
// A failed request deletes the uploads, and the driver hands these
// same objects to the next route — which can only use the path.
result.restore();
expect(part).toEqual({ puter_path: path });
});
it('undoes the inline text error swapped in for a part that could not load', async () => {
const { actor } = await makeUser();
const { client } = makeAnthropicStub();
const path = `/${actor.user!.username!}/Documents/missing.png`;
const part: Record<string, unknown> = { puter_path: path };
const result = await processPuterPathUploads(
client,
[{ content: [part] }],
server.stores,
server.services.fs,
actor,
);
expect(part.type).toBe('text');
result.restore();
expect(part).toEqual({ puter_path: path });
});
});
describe('claude processPuterPathUploads rejection paths', () => {
it('replaces the part with an inline error when the caller is unauthenticated', async () => {
const { client, upload } = makeAnthropicStub();
@@ -39,6 +39,12 @@ interface ContentPart {
export interface ClaudeUploadResult {
/** File IDs uploaded this request; caller deletes them after completion. */
fileIds: string[];
/**
* Puts every rewritten part back to the `puter_path` it arrived as. Call it
* when the request fails: the driver hands the same message objects to the
* next fallback route, which cannot resolve our `file_id`s.
*/
restore: () => void;
}
/**
@@ -66,15 +72,29 @@ export async function processPuterPathUploads(
if (part?.puter_path) parts.push(part);
}
}
if (parts.length === 0) return { fileIds: [] };
if (parts.length === 0) return { fileIds: [], restore: () => {} };
const fileIds: string[] = [];
const restores: Array<() => void> = [];
await Promise.all(
parts.map((part) =>
processPart(part, anthropic, stores, fsService, actor, fileIds),
processPart(
part,
anthropic,
stores,
fsService,
actor,
fileIds,
restores,
),
),
);
return { fileIds };
return {
fileIds,
restore: () => {
for (const undo of restores) undo();
},
};
}
async function processPart(
@@ -84,8 +104,19 @@ async function processPart(
fsService: FSService,
actor: Actor | undefined,
fileIds: string[],
restores: Array<() => void>,
): Promise<void> {
const path = part.puter_path!;
// Only the path needs to come back: `type`/`source` on success and
// `type`/`text` on failure are the sole fields written below, and the
// next provider's uploader keys off `puter_path` alone. Undoing those by
// name rather than snapshotting the part keeps nothing else alive.
restores.push(() => {
delete part.type;
delete part.text;
delete part.source;
part.puter_path = path;
});
delete part.puter_path;
if (!actor?.user?.id) {
@@ -82,8 +82,7 @@ let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
const KV_KEY = 'togetherai:models';
// Together's `models.list()` returns API-shaped rows; the provider
// coerces them to IChatModel and prepends a synthetic
// `model-fallback-test-1` row at the end. Costs (per million):
// coerces them to IChatModel. Costs (per million):
// Llama-3.1-8B: input=18, output=18; Qwen-7B: input=20, output=20.
const SAMPLE_API_MODELS = [
{
@@ -208,8 +207,6 @@ describe('TogetherAIProvider model catalog', () => {
'togetherai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo',
);
expect(ids).toContain('Meta-Llama-3.1-8B-Instruct-Turbo');
// The synthetic fallback-test model is appended.
expect(ids).toContain('model-fallback-test-1');
});
it('reserves headroom under the context length for the output cap', async () => {
@@ -357,22 +354,6 @@ describe('TogetherAIProvider.complete request shape', () => {
});
});
it('throws synthetic model-fallback-test-1 BEFORE hitting the SDK', async () => {
const { provider } = makeProvider();
await expect(
withTestActor(() =>
provider.complete({
model: 'model-fallback-test-1',
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toThrow(/Model Fallback Test 1/);
expect(createMock).not.toHaveBeenCalled();
expect(recordSpy).not.toHaveBeenCalled();
});
// Together's APIError carries the whole response body on `.error`, so the
// provider message sits at `.error.error.message` — one level deeper than
// the OpenAI SDK puts it.
@@ -102,20 +102,6 @@ export class TogetherAIProvider implements IChatProvider {
}
}
models.push({
id: 'model-fallback-test-1',
name: 'Model Fallback Test 1',
context: 1000,
costs_currency: 'usd-cents',
input_cost_key: 'input',
output_cost_key: 'output',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 10,
},
max_tokens: 1000,
});
kv.set(this.#kvKey, models, { EX: 15 * 60 });
return models;
}
@@ -132,10 +118,6 @@ export class TogetherAIProvider implements IChatProvider {
max_tokens,
temperature,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
if (model === 'model-fallback-test-1') {
throw new Error('Model Fallback Test 1');
}
const actor = Context.get('actor');
const models = await this.models();
const modelLower = model.toLowerCase();
@@ -133,12 +133,44 @@ describe('compareModelPreference', () => {
const long = {
...geminiModel('gemini-2.5-flash'),
id: 'some-vendor/gemini-x-2025-preview',
provider: 'azure-openai',
provider: 'xai',
};
expect(winner(long, short).id).toBe('gemini-x');
});
it('serves Azure ahead of the vendor it fronts, whichever registered first', () => {
// Azure's catalog copies the vendor's prices, so cost cannot decide
// and the winner used to be whichever provider registered first.
const azure = {
...geminiModel('gemini-2.5-flash'),
id: 'gpt-x',
provider: 'azure-openai',
};
const vendor = { ...azure, provider: 'openai-completion' };
expect(winner(vendor, azure).provider).toBe('azure-openai');
expect(winner(azure, vendor).provider).toBe('azure-openai');
});
it('keeps Azure first even when its copied cost table drifts higher', () => {
const azure = {
...geminiModel('gemini-2.5-pro'),
id: 'gpt-x',
provider: 'azure-openai',
};
const vendor = {
...geminiModel('gemini-2.5-flash-lite'),
id: 'gpt-x',
provider: 'openai-completion',
};
expect(azure.costs.prompt_tokens).toBeGreaterThan(
vendor.costs.prompt_tokens as number,
);
expect(winner(vendor, azure).provider).toBe('azure-openai');
});
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.
@@ -33,13 +33,25 @@ export const AGGREGATOR_PROVIDERS = new Set([
'hoonify',
]);
// Lower rank is served first. `openrouter` and `together-ai` sit at the very
// bottom, in that order, behind the other resellers.
/**
* Direct providers served ahead of every other direct provider, at any price.
* Azure AI Foundry fronts OpenAI and xAI models at mirrored prices, so naming
* it here keeps it first even if the two cost tables drift. The rank is
* unconditional, not scoped to the vendors it fronts: any other provider that
* ever publishes one of these model ids loses the bucket however cheaply it
* quotes.
*/
const PREFERRED_PROVIDERS = new Set(['azure-openai', 'azure-openai-responses']);
// Lower rank is served first: preferred direct providers, then the other
// direct vendors, then the resellers with `openrouter` and `together-ai` at
// the very bottom, in that order.
const providerRank = (provider?: string): number => {
if (provider === 'together-ai') return 3;
if (provider === 'openrouter') return 2;
if (provider && AGGREGATOR_PROVIDERS.has(provider)) return 1;
return 0;
if (provider === 'together-ai') return 4;
if (provider === 'openrouter') return 3;
if (provider && AGGREGATOR_PROVIDERS.has(provider)) return 2;
if (provider && PREFERRED_PROVIDERS.has(provider)) return 0;
return 1;
};
/**
@@ -85,11 +97,12 @@ export const isIdentityKey = (key: string): boolean =>
/**
* 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.
* Preferred direct providers outrank the other direct vendors, and 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,