Commit 4c149373 authored by Tashfeen's avatar Tashfeen

fix(cloudflare): normalize null content to empty string for tool round-trips

Cloudflare's OpenAI-compat endpoint rejects assistant messages with
content: null, even when tool_calls are present (standard OpenAI format).
Added normalizeMessages() that converts null content to "" before dispatch,
plus a regression test covering the null-content + tool_calls case.

Also credits @moaaz12-web in README Contributors for the tool-calling PR.
parent 1a80b0a1
...@@ -245,11 +245,17 @@ Contributors very welcome! Good first PRs: ...@@ -245,11 +245,17 @@ Contributors very welcome! Good first PRs:
```bash ```bash
npm install npm install
npm run dev # server on :3001, dashboard on :5173, both with HMR npm run dev # server on :3001, dashboard on :5173, both with HMR
npm test # vitest — 69 tests across providers, routes, router, ratelimit npm test # vitest — 75 tests across providers, routes, router, ratelimit
``` ```
PRs should include a test, keep the existing test suite green, and match the `.editorconfig` / tsconfig defaults already in the repo. Issues and discussions are open. PRs should include a test, keep the existing test suite green, and match the `.editorconfig` / tsconfig defaults already in the repo. Issues and discussions are open.
### Contributors
Thanks to everyone who's helped improve FreeLLMAPI:
- [@moaaz12-web](https://github.com/moaaz12-web) — tool-calling support across providers (#3)
## Terms of Service review ## Terms of Service review
A self-hosted, single-user, personal-use setup was reviewed against each provider's ToS (April 2026). Summary: A self-hosted, single-user, personal-use setup was reviewed against each provider's ToS (April 2026). Summary:
......
...@@ -53,4 +53,43 @@ describe('CloudflareProvider', () => { ...@@ -53,4 +53,43 @@ describe('CloudflareProvider', () => {
provider.chatCompletion('no-colon-here', [{ role: 'user', content: 'Hi' }], 'model') provider.chatCompletion('no-colon-here', [{ role: 'user', content: 'Hi' }], 'model')
).rejects.toThrow(/account_id:api_token/); ).rejects.toThrow(/account_id:api_token/);
}); });
it('should convert null assistant content to empty string (CF rejects null)', async () => {
let capturedBody: any = null;
vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
capturedBody = JSON.parse((init as any).body);
return {
ok: true,
json: () => Promise.resolve({
id: 'chatcmpl-cf',
object: 'chat.completion',
created: 123,
model: '@cf/meta/llama-3.1-70b-instruct',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
} as any;
});
await provider.chatCompletion(
'abc123:token',
[
{ role: 'user', content: 'Weather?' },
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Karachi"}' },
}],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"temp":30}' },
],
'@cf/meta/llama-3.1-70b-instruct',
);
expect(capturedBody.messages[1].content).toBe('');
expect(capturedBody.messages[1].tool_calls).toHaveLength(1);
});
}); });
...@@ -20,6 +20,14 @@ export class CloudflareProvider extends BaseProvider { ...@@ -20,6 +20,14 @@ export class CloudflareProvider extends BaseProvider {
return { accountId: apiKey.slice(0, sep), token: apiKey.slice(sep + 1) }; return { accountId: apiKey.slice(0, sep), token: apiKey.slice(sep + 1) };
} }
// Cloudflare's OpenAI-compat endpoint rejects `content: null` on assistant
// messages that carry tool_calls, even though the OpenAI spec allows it.
private normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.map(m =>
m.content === null ? { ...m, content: '' } : m,
);
}
async chatCompletion( async chatCompletion(
apiKey: string, apiKey: string,
messages: ChatMessage[], messages: ChatMessage[],
...@@ -37,7 +45,7 @@ export class CloudflareProvider extends BaseProvider { ...@@ -37,7 +45,7 @@ export class CloudflareProvider extends BaseProvider {
}, },
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages, messages: this.normalizeMessages(messages),
temperature: options?.temperature, temperature: options?.temperature,
max_tokens: options?.max_tokens, max_tokens: options?.max_tokens,
top_p: options?.top_p, top_p: options?.top_p,
...@@ -74,7 +82,7 @@ export class CloudflareProvider extends BaseProvider { ...@@ -74,7 +82,7 @@ export class CloudflareProvider extends BaseProvider {
}, },
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages, messages: this.normalizeMessages(messages),
temperature: options?.temperature, temperature: options?.temperature,
max_tokens: options?.max_tokens, max_tokens: options?.max_tokens,
top_p: options?.top_p, top_p: options?.top_p,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment