docs(ai): polish BYOK compatibility guidance

- Publish the compatibility reference in the VitePress site and link localized AI guides to the canonical English page
- Correct current Anthropic and credential-storage behavior
- Make CORS, streaming, pricing, context, and reasoning measurements reproducible and security-safe
This commit is contained in:
Danila Poyarkov 2026-08-03 19:19:38 +03:00
parent 1ebaac7be2
commit 0036d762e2
10 changed files with 365 additions and 283 deletions

View file

@ -1,276 +0,0 @@
# BYOK provider & model compatibility
OpenPencil has no backend. When you bring your own API key, the browser talks to the provider
directly — so a provider only works in the **web build** if it sets CORS headers correctly, and a
model is only useful for the AI chat panel if its **streaming tool calls** are well-formed.
Both of those vary a lot between providers, and neither is documented by the providers themselves.
This page records what has actually been measured.
**This is a living list — please add to it.** See [Contributing results](#contributing-results).
Results are dated because providers change behaviour without announcement. Treat anything older
than a few months as unverified.
---
## Provider CORS support
Whether the **web build** can reach the provider at all. The desktop build bypasses CORS entirely
(it routes through `tauriFetch`), so a ❌ here still works on desktop.
| Provider | Base URL | Browser | Notes | Tested |
| -------------------- | ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| OpenRouter | `https://openrouter.ai/api/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| OpenAI | `https://api.openai.com/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Groq | `https://api.groq.com/openai/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Google | `https://generativelanguage.googleapis.com` | ✅ | echoes origin | 2026-07-30 |
| DeepSeek | `https://api.deepseek.com` | ✅ | echoes origin | 2026-07-30 |
| Z.ai | `https://api.z.ai/api/anthropic` | ✅ | echoes origin | 2026-07-30 |
| MiniMax | `https://api.minimax.io/v1` | ✅ | echoes origin | 2026-07-30 |
| Nebius AI Studio | `https://api.studio.nebius.com/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Nebius Token Factory | `https://api.tokenfactory.<region>.nebius.com/v1` | ✅ | `ACAO: *`; catalog differs per region | 2026-07-30 |
| Anthropic | `https://api.anthropic.com` | ⚠️ | Needs `anthropic-dangerous-direct-browser-access: true`, which we don't yet send — see [#436](https://github.com/open-pencil/open-pencil/issues/436) | 2026-07-30 |
| Scaleway | `https://api.scaleway.ai/<project-id>/v1` | ⚠️ | `ACAO: *` on success, **absent on errors** — a bad key reads as a network failure | 2026-07-31 |
| TensorX | `https://api.tensorx.ai/v1` | ⚠️ | `ACAO: *` on success, **absent on errors** (401/403/500). Reported to their support | 2026-07-31 |
### The common failure
A provider that answers the `OPTIONS` preflight correctly but omits `Access-Control-Allow-Origin`
on the **actual response** will fail in the browser with a generic `Failed to fetch`. OpenPencil
surfaces that as _"Could not reach this endpoint from the browser"_ — which is indistinguishable
from a wrong API key. **Always verify with curl before assuming the app is at fault.**
### Test the success path, not just an error
Several providers (Scaleway, TensorX) send CORS headers on a `200` but **not** on `401`/`403`/`500`
— errors are rejected at a gateway that never adds the header. So an unauthenticated probe tells
you nothing: it looks identical to a provider that is genuinely broken.
Always test with a **valid key and a successful response**. The inference only runs one way — if
error responses carry `Access-Control-Allow-Origin`, the success path will too, but the reverse
does not hold.
Providers in this state are marked ⚠️ rather than ❌: normal use works, but every failure the user
hits — expired key, exhausted quota, bad model name — reaches them as an opaque network error
instead of the real reason.
---
## Model tool-calling quality
The AI chat panel is an agent loop, so a model is only usable if it reliably emits **streaming**
tool calls that the AI SDK can parse. Two independent things go wrong:
- **`id` correctness** — if a streamed `tool_calls` delta introduces a new index without an `id`,
`@ai-sdk/openai` throws `Expected 'id' to be a string.` and the stream dies.
- **Argument validity** — arguments are streamed in fragments and concatenated. If the fragments
are misrouted, the result isn't valid JSON and the tool silently never fires.
Cost matters as much as correctness here. The chat panel is an agent loop that runs up to
`MAX_AGENT_STEPS` (50) and resends the tool schemas on every step, so it is input-token heavy and
a high per-token price compounds fast.
Capability columns are **measured**, not taken from a spec sheet — see
[Auto-lookup via models.dev](#auto-lookup-via-modelsdev) for why that distinction matters.
`Tools` counts prompts that produced at least one tool call, over `id`-correctness and
argument-validity totals. `Vision` is verified by sending a solid-colour image and asking the
model to name it.
**Record the output-token budget with every result** — it changes the outcome. Scaleway rows below
were measured at `max_tokens: 16384`; Nebius and TensorX rows at 2000, so their "emits calls"
figures are not directly comparable and any failure there may be starvation rather than incapacity.
| Model | Provider | /1M inout | Ctx | Tools @16k | Vision | Verdict | Tested |
| ------------------------------------- | -------- | -------------- | ---- | ------------------------ | ------ | ----------------------------------------------------------------------------------------------------- | ---------- |
| `mistral-small-3.2-24b-instruct-2506` | Scaleway | €0.15 €0.35 | 128k | ✅ 3/3, 0/238, 6/6 | ✅ | **Recommended** — cheapest with vision, fully clean | 2026-07-31 |
| `gemma-4-26b-a4b-it` | Scaleway | €0.25 €0.50 | 256k | ✅ 3/3, 0/77, 8/8 | ✅ | Same but 2× the context — pick this for large documents | 2026-07-31 |
| `gpt-oss-120b` | Scaleway | €0.15 €0.60 | 128k | ✅ 3/3, 0/118, 3/3 | ❌ | Cheap and clean, but text-only | 2026-07-31 |
| `qwen3.6-35b-a3b` | Scaleway | €0.25 €1.50 | 128k | ✅ 3/3, 0/106, 9/9 | ✅ | Clean; heavy reasoner, needs the larger budget | 2026-07-31 |
| `qwen3.5-397b-a17b` | Scaleway | €0.60 €3.60 | 256k | ✅ 3/3, 0/92, 10/10 | ✅ | Most calls per turn; strongest but priciest here | 2026-07-31 |
| `mistral-medium-3.5-128b` | Scaleway | €1.50 €7.50 | 256k | not tested | ✅ | Vision confirmed; tool calling unverified | 2026-07-31 |
| `glm-5.2` | Scaleway | €1.80 €5.50 | 256k | not tested | ❌ | API rejects images: "not a multimodal model" | 2026-07-31 |
| `moonshotai/Kimi-K2.7-Code` | Nebius | $0.95 $4.00 | 262k | ✅ 3/3, 0/114, 4/4 @2k | ❌ | Clean, but pricier than the Scaleway set | 2026-07-30 |
| `moonshotai/Kimi-K3` | Nebius | $3.00 $15.00 | 1M | ✅ 3/3, 0 bad, 8/8 @2k | ❌ | Flawless, but ~20× the cost of `gpt-oss-120b` | 2026-07-30 |
| `moonshotai/kimi-k3` | TensorX | — | — | ✅ 2/3, 0 bad, 7/7 @2k | — | As above | 2026-07-30 |
| `openai/gpt-oss-120b` | Nebius | $0.15 $0.60 | 128k | ❌ misroutes final chunk | ❌ | Broken on Nebius only — same model is fine on Scaleway | 2026-07-30 |
| `moonshotai/Kimi-K2.6` | Nebius | — | — | ⚠️ 1/3 @2k | — | Finished on `stop`, not `length` — likely a real failure, but re-test at 16k before trusting this row | 2026-07-30 |
### Auto-lookup via models.dev
[models.dev](https://models.dev) publishes a machine-readable catalog at
<https://models.dev/api.json> (~3 MB, 176 providers, including `scaleway`, `nebius`, `groq` and
`openrouter`). Each model carries `tool_call`, `attachment` (vision), `reasoning`,
`cost.input`/`cost.output`, `limit.context` and `modalities`.
```bash
curl -s https://models.dev/api.json \
| jq '.scaleway.models["gemma-4-26b-a4b-it"] | {tool_call, attachment, reasoning, cost, limit}'
```
**Use it to prefill a row, not to fill in the measured columns.** Checked against the models above,
it was wrong on two of seven:
| Model | models.dev | Measured |
| ---------------------------------- | ------------ | -------------------------------------------------- |
| `scaleway/gpt-oss-120b` | vision true | Refuses: "I don't have the ability to view images" |
| `scaleway/mistral-small-3.2-24b-…` | vision false | Correctly names the colour in an image |
It also lists `nebius/openai/gpt-oss-120b` as vision `false` while marking the Scaleway copy
`true` — the same model, contradicting itself — and reports `tool_call: true` for every model
here, including `qwen3.6-35b-a3b` (silent on 2/3 prompts) and Nebius's `gpt-oss-120b` (broken by
the deployment bug below).
That's the general shape of it: models.dev describes a model's **nominal** capabilities, while
what breaks in practice is a property of the **deployment**. Price and context window are safe to
copy from it; tool-calling and vision are not.
Prices are provider list prices gathered from public pricing aggregators, not measured — confirm
against your provider's own page before relying on them.
The same model can behave differently on different providers — `gpt-oss-120b` is broken on Nebius
and clean on Scaleway. **Record the provider alongside the model; a model row without one is not
reproducible.**
Note that "emits calls" counts prompts that produced at least one tool call, not how many. Models
differ in style: `qwen3.5-397b-a17b` issued 34 parallel calls per turn where `gpt-oss-120b` issued
one. In a 50-step agent loop, one-call-then-observe is legitimate rather than weaker, so don't read
the count as a quality ranking.
### Known issue: vLLM misroutes the final argument chunk
On some vLLM deployments the closing `}` of a tool call's arguments is emitted under `index + 1`
with no `id`:
```
1 [{"index":0,"id":"chatcmpl-tool-…","function":{"arguments":"","name":"create_node"},"type":"function"}]
… deltas 211 stream the arguments, all index 0
12 [{"index":1,"function":{"arguments":"}"}}] ← wrong index, no id
```
This both crashes the stream _and_ leaves call 0's arguments unterminated. A client-side workaround
must reattach argument-only deltas (no `id`, no `function.name`) to the most recently opened call,
ignoring the claimed index — synthesizing a missing `id` alone converts a visible crash into a
silent no-op.
Models that emit one complete tool call per chunk (Kimi-K3 on every provider tested) cannot hit
this bug at all.
**This is a deployment bug, not a model bug.** `gpt-oss-120b` misroutes on Nebius but streams
cleanly on Scaleway — 0 violations across 135 deltas. Before writing a client-side workaround,
check whether another provider serving the same model is unaffected.
### Known issue: reasoning models starve on a small output budget
A reasoning model can spend its **entire** output budget thinking and never emit a tool call. The
result is `finish_reason: length` with zero tool calls — no error, no partial output. From the
outside it is indistinguishable from a model that simply can't call tools.
Measured on `qwen3.6-35b-a3b`, same prompts, only `max_tokens` changed:
| `max_tokens` | Result |
| ------------ | ----------------------------------------------- |
| 2000 | 1/3 prompts — the rest hit `length` mid-thought |
| 16384 | 3/3 prompts, 0 bad ids, 9/9 valid args |
One prompt needed 10,417 characters of reasoning before its first tool call. **This page originally
scored two models as broken purely because the probe used 2000 tokens.** If a model produces no tool
calls, check `finish_reason` before concluding anything — `length` means starved, `stop` means it
genuinely declined.
`reasoning_effort` is the other lever, though supported values are provider-specific:
| Setting | Reasoning | Tool calls |
| ---------------------------------------------------- | --------- | ---------- |
| default | 6,583 ch | ❌ 0 |
| `"reasoning_effort": "none"` | 0 ch | ✅ 3 |
| `"reasoning_effort": "low"` | 5,088 ch | ✅ 3 |
| `"chat_template_kwargs": {"enable_thinking": false}` | 5,133 ch | ✅ 3 |
Only `"none"` actually eliminated reasoning on Scaleway; `"low"` had no measurable effect.
OpenPencil cannot send this today — see
[#454](https://github.com/open-pencil/open-pencil/issues/454).
### Known issue: reasoning models and the connection test
Some models (e.g. Kimi-K2.6) spend output tokens on `reasoning` before `content`. The connection
test uses `maxOutputTokens: 1`, so `content` comes back `null`. The test still passes because it
only checks that the request didn't throw — but don't tighten it to assert on returned text, or
reasoning models will fail it spuriously.
---
## Contributing results
Add a row, keep it dated, and say how you tested. Negative results are as useful as positive ones.
### 1. Test CORS
Run this from a shell — curl isn't subject to CORS, so you're inspecting headers, not behaviour.
What matters is `access-control-allow-origin` on the **POST**, not the `OPTIONS`.
```bash
BASE="https://api.example.com/v1"
KEY="sk-…"
# The real response — this is the one that counts
curl -sD - -o /dev/null -X POST "$BASE/chat/completions" \
-H "Origin: https://openpencil.dev" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"model":"MODEL","max_tokens":5,"messages":[{"role":"user","content":"hi"}]}' \
| grep -i "^HTTP\|access-control"
```
✅ if `access-control-allow-origin` is present on the POST response (`*` or your origin).
❌ if it only appears on the `OPTIONS` preflight.
### 2. Test tool-call streaming
Ask the model to make several tool calls, then check every call has an `id` and that the
concatenated arguments parse as JSON:
```bash
curl -sN -X POST "$BASE/chat/completions" \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"model":"MODEL","stream":true,"max_tokens":2000,
"messages":[{"role":"user","content":"Draw a frog: green ellipse body, two white eye circles, a mouth line. Use create_node for each."}],
"tools":[{"type":"function","function":{"name":"create_node","description":"Create a node on the canvas",
"parameters":{"type":"object","properties":{"type":{"type":"string","enum":["ELLIPSE","RECTANGLE","LINE","TEXT"]},
"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number"},"height":{"type":"number"},
"fill":{"type":"string"}},"required":["type","x","y"]}}}]}' \
| grep -o '"tool_calls":\[[^]]*\]'
```
Check each first-delta-per-index has an `id`, and that concatenating every `arguments` fragment
per index yields valid JSON. Use more than one prompt — failures are often intermittent and depend
on where the JSON happens to land on a chunk boundary.
### 3. Add your row
Include the date, the exact model ID and base URL, and a one-line verdict. If something is broken,
say what the failure looks like from the user's side — that's what makes the row actionable.
Include the provider's list price too. A model that streams flawlessly but costs 25× the
alternative isn't the right default for an agent loop, and a table without prices makes that easy
to miss.
**And record the `max_tokens` you used.** A reasoning model starved of output budget produces no
tool calls and looks broken. Two models on this page were wrongly marked "avoid" for exactly that
reason. Use at least 16384, and check `finish_reason` before recording a failure.
### A note on regional catalogs
Some providers serve different models per region behind different hostnames. Nebius Token Factory,
for example, carries Kimi-K3 on `eu-west2` but not on `us-central1`. Always record the exact base
URL you tested — "provider X doesn't have model Y" is only true for the endpoint you checked.
---
## Security note
BYOK in a browser means the user's API key lives in browser storage. OpenPencil encrypts it at rest
(AES-GCM in IndexedDB, with a non-extractable `CryptoKey`) and defaults to memory-only unless the
user opts into "remember"; the desktop build uses the OS keychain instead. But any script running
on the origin can still use the key in place, so prefer scoped keys with spend caps where the
provider offers them.

View file

@ -68,6 +68,14 @@ export const programmableSidebar = (
{ text: labels.jsxRenderer, link: `${prefix}/programmable/jsx-renderer` },
{ text: labels.mcpServer, link: `${prefix}/programmable/mcp-server` },
{ text: labels.aiChat, link: `${prefix}/programmable/ai-chat` },
...(!prefix
? [
{
text: 'BYOK Compatibility',
link: '/programmable/byok-provider-compatibility',
},
]
: []),
{ text: labels.collaboration, link: `${prefix}/programmable/collaboration` },
],
},

View file

@ -25,7 +25,7 @@ Drücken Sie <kbd>⌘</kbd><kbd>J</kbd> (<kbd>Ctrl</kbd> + <kbd>J</kbd>), um den
| **OpenAI-kompatibel** | Jeder Endpunkt mit OpenAI-API-Format | Eigene Basis-URL + Schlüssel. Unterstützt Completions- und Responses-API-Umschaltung. |
| **Anthropic-kompatibel** | Jeder Endpunkt mit Anthropic-API-Format | Eigene Basis-URL + Schlüssel |
Kein Backend, kein Abonnement — Ihr Schlüssel kommuniziert direkt mit dem Anbieter.
Kein Backend, kein Abonnement — Ihr Schlüssel kommuniziert direkt mit dem Anbieter. Browser-Anfragen unterliegen den CORS-Richtlinien des Anbieters, und Modelldeployments unterscheiden sich bei der Zuverlässigkeit gestreamter Tool-Aufrufe. Messwerte und reproduzierbare Tests finden Sie in der englischen Referenz zur [BYOK-Anbieter- und Modellkompatibilität](/programmable/byok-provider-compatibility).
## Funktionen

View file

@ -25,7 +25,7 @@ Presiona <kbd>⌘</kbd><kbd>J</kbd> (<kbd>Ctrl</kbd> + <kbd>J</kbd>) para abrir
| **Compatible con OpenAI** | Cualquier endpoint con formato de API OpenAI | URL base personalizada + clave. Admite alternancia entre API de Completions y Responses. |
| **Compatible con Anthropic** | Cualquier endpoint con formato de API Anthropic | URL base personalizada + clave |
Sin backend, sin suscripción — tu clave se comunica directamente con el proveedor.
Sin backend ni suscripción: tu clave se comunica directamente con el proveedor. Las solicitudes del navegador están sujetas a la política CORS del proveedor, y la fiabilidad de las llamadas a herramientas en streaming varía entre despliegues. Consulta la referencia en inglés sobre [compatibilidad de proveedores y modelos BYOK](/programmable/byok-provider-compatibility) para ver resultados medidos y pruebas reproducibles.
## Funciones

View file

@ -25,7 +25,7 @@ Appuyez sur <kbd>⌘</kbd><kbd>J</kbd> (<kbd>Ctrl</kbd> + <kbd>J</kbd>) pour ouv
| **Compatible OpenAI** | Tout endpoint au format API OpenAI | URL de base personnalisée + clé. Supporte le basculement entre API Completions et Responses. |
| **Compatible Anthropic** | Tout endpoint au format API Anthropic | URL de base personnalisée + clé |
Pas de backend, pas d'abonnement — votre clé communique directement avec le fournisseur.
Pas de backend ni dabonnement : votre clé communique directement avec le fournisseur. Les requêtes du navigateur sont soumises à la politique CORS du fournisseur, et la fiabilité des appels doutils en streaming varie selon les déploiements. Consultez la référence en anglais sur la [compatibilité des fournisseurs et modèles BYOK](/programmable/byok-provider-compatibility) pour les résultats mesurés et les tests reproductibles.
## Fonctionnalités

View file

@ -25,7 +25,7 @@ Premi <kbd>⌘</kbd><kbd>J</kbd> (<kbd>Ctrl</kbd> + <kbd>J</kbd>) per aprire l'a
| **Compatibile OpenAI** | Qualsiasi endpoint con formato API OpenAI | URL base personalizzato + chiave. Supporta toggle tra API Completions e Responses. |
| **Compatibile Anthropic** | Qualsiasi endpoint con formato API Anthropic | URL base personalizzato + chiave |
Nessun backend, nessun abbonamento — la tua chiave comunica direttamente con il provider.
Nessun backend o abbonamento: la tua chiave comunica direttamente con il provider. Le richieste del browser dipendono dalla policy CORS del provider e laffidabilità delle chiamate agli strumenti in streaming varia tra i deployment. Consulta il riferimento in inglese sulla [compatibilità di provider e modelli BYOK](/programmable/byok-provider-compatibility) per risultati misurati e test riproducibili.
## Funzionalità

View file

@ -25,7 +25,7 @@ Naciśnij <kbd>⌘</kbd><kbd>J</kbd> (<kbd>Ctrl</kbd> + <kbd>J</kbd>), aby otwor
| **Kompatybilny z OpenAI** | Dowolny endpoint w formacie API OpenAI | Własny bazowy URL + klucz. Obsługuje przełączanie między API Completions i Responses. |
| **Kompatybilny z Anthropic** | Dowolny endpoint w formacie API Anthropic | Własny bazowy URL + klucz |
Bez backendu, bez subskrypcji — Twój klucz komunikuje się bezpośrednio z dostawcą.
Bez backendu i subskrypcji — klucz komunikuje się bezpośrednio z dostawcą. Żądania z przeglądarki podlegają zasadom CORS dostawcy, a niezawodność strumieniowych wywołań narzędzi zależy od wdrożenia modelu. Zobacz anglojęzyczną stronę [zgodności dostawców i modeli BYOK](/programmable/byok-provider-compatibility), aby poznać zmierzone wyniki i odtwarzalne testy.
## Funkcje

View file

@ -29,7 +29,7 @@ You can configure multiple reusable models and separately assign models for desi
| **OpenAI-compatible** | Any endpoint with OpenAI API format | Custom base URL + key. Supports Completions and Responses API toggle. |
| **Anthropic-compatible** | Any endpoint with Anthropic API format | Custom base URL + key |
No backend, no subscription — your key talks directly to the provider.
No backend, no subscription — your key talks directly to the provider. Browser requests are subject to each provider's CORS policy, and model deployments vary in how reliably they stream tool calls. See [BYOK provider and model compatibility](./byok-provider-compatibility) for measured results and reproduction steps.
## What It Can Do

View file

@ -0,0 +1,350 @@
# BYOK provider and model compatibility
OpenPencil has no backend. When you bring your own API key, the browser talks to the provider
directly — so a provider only works in the **web build** if it sets CORS headers correctly, and a
model is only useful for the AI chat panel if its **streaming tool calls** are well-formed.
Both of those vary a lot between providers, and neither is documented by the providers themselves.
This page records what has actually been measured.
**This is a living list — please add to it.** See [Contributing results](#contributing-results).
Results are dated because providers change behaviour without announcement. Treat anything older
than a few months as unverified.
---
## Provider CORS support
Whether the **web build** can reach the provider at all. The desktop build bypasses CORS entirely
(it routes through `tauriFetch`), so a ❌ here still works on desktop.
| Provider | Base URL | Browser | Notes | Tested |
| -------------------- | ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------- | ---------- |
| OpenRouter | `https://openrouter.ai/api/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| OpenAI | `https://api.openai.com/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Groq | `https://api.groq.com/openai/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Google | `https://generativelanguage.googleapis.com` | ✅ | echoes origin | 2026-07-30 |
| DeepSeek | `https://api.deepseek.com` | ✅ | echoes origin | 2026-07-30 |
| Z.ai | `https://api.z.ai/api/anthropic` | ✅ | echoes origin | 2026-07-30 |
| MiniMax | `https://api.minimax.io/v1` | ✅ | echoes origin | 2026-07-30 |
| Nebius AI Studio | `https://api.studio.nebius.com/v1` | ✅ | `ACAO: *` | 2026-07-30 |
| Nebius Token Factory | `https://api.tokenfactory.<region>.nebius.com/v1` | ✅ | `ACAO: *`; catalog differs per region | 2026-07-30 |
| Anthropic | `https://api.anthropic.com` | ✅ | Requires `anthropic-dangerous-direct-browser-access: true`; OpenPencil sends it in the web app | 2026-07-30 |
| Scaleway | `https://api.scaleway.ai/<project-id>/v1` | ⚠️ | `ACAO: *` on success, **absent on errors** — a bad key reads as a network failure | 2026-07-31 |
| TensorX | `https://api.tensorx.ai/v1` | ⚠️ | `ACAO: *` on success, **absent on errors** (401/403/500). Reported to their support | 2026-07-31 |
### The common failure
A provider that answers the `OPTIONS` preflight correctly but omits `Access-Control-Allow-Origin`
on the **actual response** will fail in the browser with a generic `Failed to fetch`. OpenPencil
surfaces that as _"Could not reach this endpoint from the browser"_ — which is indistinguishable
from a wrong API key. **Always verify with curl before assuming the app is at fault.**
### Test the success path, not just an error
Several providers (Scaleway, TensorX) send CORS headers on a `200` but **not** on `401`/`403`/`500`
— errors are rejected at a gateway that never adds the header. So an unauthenticated probe tells
you nothing: it looks identical to a provider that is genuinely broken.
Always test with a **valid key and a successful response**. Do not infer success-path CORS from
an error response: providers can route successful and failed requests through different middleware.
Record error-response CORS separately so users know whether an expired key, exhausted quota, or bad
model name will surface as a useful API error or an opaque network failure.
---
## Model tool-calling quality
The AI chat panel is an agent loop, so a model is only usable if it reliably emits **streaming**
tool calls that the AI SDK can parse. Two independent things go wrong:
- **`id` correctness** — if a streamed `tool_calls` delta introduces a new index without an `id`,
`@ai-sdk/openai` throws `Expected 'id' to be a string.` and the stream dies.
- **Argument validity** — arguments are streamed in fragments and concatenated. If the fragments
are misrouted, the result isn't valid JSON and the tool silently never fires.
Cost matters as much as correctness here. The chat panel is an agent loop that runs up to
`MAX_AGENT_STEPS` (50) and resends the tool schemas on every step, so it is input-token heavy and
a high per-token price compounds fast.
Vision is measured by sending a solid-colour image and asking the model to name it. Tool-call and
vision results are measurements; context limits and prices are catalog metadata, identified below
with their source dates.
**Record the output-token budget with every result** — it changes the outcome. Scaleway rows below
were measured at `max_tokens: 16384`; Nebius and TensorX rows at 2000, so their "emits calls"
figures are not directly comparable and any failure there may be starvation rather than incapacity.
Most results below used provider-default reasoning settings. The separate reasoning comparison
explicitly identifies each override it tested. Future rows must record any non-default
`reasoning_effort` or `chat_template_kwargs.enable_thinking` value.
The `Tools` cells use `prompts, bad ID deltas, valid arguments`: for example,
`3/3 prompts · 0/238 bad IDs · 6/6 valid args`. A prompt passes when it emits at least one tool
call; ID counts cover inspected streamed tool-call deltas; argument counts cover completed calls
whose concatenated argument fragments parse as JSON.
| Model | Provider | /1M inout | Ctx¹ | Tools | Vision | Verdict | Tested |
| ------------------------------------- | -------- | -------------- | ---- | -------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------- | ---------- |
| `mistral-small-3.2-24b-instruct-2506` | Scaleway | €0.15 €0.35 | 128k | ✅ 3/3 prompts · 0/238 bad IDs · 6/6 valid args | ✅ | **Recommended** — cheapest with vision, fully clean | 2026-07-31 |
| `gemma-4-26b-a4b-it` | Scaleway | €0.25 €0.50 | 256k | ✅ 3/3 prompts · 0/77 bad IDs · 8/8 valid args | ✅ | Same but 2× the context — pick this for large documents | 2026-07-31 |
| `gpt-oss-120b` | Scaleway | €0.15 €0.60 | 128k | ✅ 3/3 prompts · 0/118 bad IDs · 3/3 valid args | ❌ | Cheap and clean, but text-only | 2026-07-31 |
| `qwen3.6-35b-a3b` | Scaleway | €0.25 €1.50 | 128k | ✅ 3/3 prompts · 0/106 bad IDs · 9/9 valid args | ✅ | Clean; heavy reasoner, needs the larger budget | 2026-07-31 |
| `qwen3.5-397b-a17b` | Scaleway | €0.60 €3.60 | 256k | ✅ 3/3 prompts · 0/92 bad IDs · 10/10 valid args | ✅ | Most calls per turn; strongest but priciest here | 2026-07-31 |
| `mistral-medium-3.5-128b` | Scaleway | €1.50 €7.50 | 256k | — not tested | ✅ | Vision confirmed; tool calling unverified | 2026-07-31 |
| `glm-5.2` | Scaleway | €1.80 €5.50 | 256k | — not tested | ❌ | API rejects images: "not a multimodal model" | 2026-07-31 |
| `moonshotai/Kimi-K2.7-Code` | Nebius | $0.95 $4.00 | 262k | ✅ 3/3 prompts · 0/114 bad IDs · 4/4 valid args² | ❌ | Clean, but pricier than the Scaleway set | 2026-07-30 |
| `moonshotai/Kimi-K3` | Nebius | $3.00 $15.00 | 1M | ✅ 3/3 prompts · 0 bad IDs³ · 8/8 valid args² | ❌ | Flawless, but ~20× the cost of `gpt-oss-120b` | 2026-07-30 |
| `moonshotai/kimi-k3` | TensorX | — | — | ⚠️ 2/3 prompts · 0 bad IDs³ · 7/7 valid args² | — | One prompt did not emit a call | 2026-07-30 |
| `openai/gpt-oss-120b` | Nebius | $0.15 $0.60 | 128k | ❌ final argument chunk routed to the wrong index² | ❌ | Broken on Nebius only — same model is fine on Scaleway | 2026-07-30 |
| `moonshotai/Kimi-K2.6` | Nebius | — | — | ⚠️ 1/3 prompts² | — | Finished on `stop`, not `length` — likely a real failure, but re-test at 16k before trusting this row | 2026-07-30 |
¹ Nominal catalog context limit from models.dev, retrieved 2026-07-31; not measured here.
² Measured with `max_tokens: 2000`; re-test failures at 16384 or the highest supported budget.
³ The original notes recorded no malformed IDs but not the total inspected delta count.
### Auto-lookup via models.dev
[models.dev](https://models.dev) publishes a machine-readable catalog at
<https://models.dev/api.json> (~3 MB, 176 providers, including `scaleway`, `nebius`, `groq` and
`openrouter`). Each model carries `tool_call`, `attachment` (vision), `reasoning`,
`cost.input`/`cost.output`, `limit.context` and `modalities`.
```bash
curl -s https://models.dev/api.json \
| jq '.scaleway.models["gemma-4-26b-a4b-it"] | {tool_call, attachment, reasoning, cost, limit}'
```
**Use it to prefill a row, not to fill in the measured columns.** Checked against the models above,
it was wrong on two of seven:
| Model | models.dev | Measured |
| ---------------------------------- | ------------ | -------------------------------------------------- |
| `scaleway/gpt-oss-120b` | vision true | Refuses: "I don't have the ability to view images" |
| `scaleway/mistral-small-3.2-24b-…` | vision false | Correctly names the colour in an image |
It also lists `nebius/openai/gpt-oss-120b` as vision `false` while marking the Scaleway copy
`true` — the same model, contradicting itself — and reports `tool_call: true` for every model
here, including `qwen3.6-35b-a3b` (silent on 2/3 prompts) and Nebius's `gpt-oss-120b` (broken by
the deployment bug below).
That's the general shape of it: models.dev describes a model's **nominal** capabilities, while
what breaks in practice is a property of the **deployment**. Catalog prices and context windows
can prefill a row, but must be labeled as metadata with a source and retrieval date; tool-calling
and vision require live tests.
Prices shown in the table were transcribed from public pricing aggregators on 2026-07-31 rather
than measured or verified against provider invoices. Because the original notes did not preserve
per-row source URLs, treat every price as unverified historical metadata and confirm it against the
provider's current pricing page before making a decision. New rows must include a source URL and a
separate price-as-of date.
The same model can behave differently on different providers — `gpt-oss-120b` is broken on Nebius
and clean on Scaleway. **Record the provider alongside the model; a model row without one is not
reproducible.**
Note that "emits calls" counts prompts that produced at least one tool call, not how many. Models
differ in style: `qwen3.5-397b-a17b` issued 34 parallel calls per turn where `gpt-oss-120b` issued
one. In a 50-step agent loop, one-call-then-observe is legitimate rather than weaker, so don't read
the count as a quality ranking.
### Known issue: vLLM misroutes the final argument chunk
On some vLLM deployments the closing `}` of a tool call's arguments is emitted under `index + 1`
with no `id`:
```
1 [{"index":0,"id":"chatcmpl-tool-…","function":{"arguments":"","name":"create_node"},"type":"function"}]
… deltas 211 stream the arguments, all index 0
12 [{"index":1,"function":{"arguments":"}"}}] ← wrong index, no id
```
This both crashes the stream _and_ leaves call 0's arguments unterminated. Do not blindly attach
an argument-only delta to the most recently opened call: concurrent or interleaved calls make that
ambiguous and can corrupt another call. A client-side workaround may reattach the fragment only
when exactly one serialized call is open; it must reject ambiguous streams. Synthesizing a missing
`id` alone converts a visible crash into a silent no-op.
Models that emit one complete tool call per chunk (Kimi-K3 on every provider tested) cannot hit
this bug at all.
**This is a deployment bug, not a model bug.** `gpt-oss-120b` misroutes on Nebius but streams
cleanly on Scaleway — 0 violations across 135 deltas. Before writing a client-side workaround,
check whether another provider serving the same model is unaffected.
### Known issue: reasoning models starve on a small output budget
A reasoning model can spend its **entire** output budget thinking and never emit a tool call. The
result is `finish_reason: length` with zero tool calls — no error, no partial output. From the
outside it is indistinguishable from a model that simply can't call tools.
Measured on `qwen3.6-35b-a3b`, same prompts, only `max_tokens` changed:
| `max_tokens` | Result |
| ------------ | ----------------------------------------------- |
| 2000 | 1/3 prompts — the rest hit `length` mid-thought |
| 16384 | 3/3 prompts, 0 bad ids, 9/9 valid args |
One prompt needed 10,417 characters of reasoning before its first tool call. **This page originally
scored two models as broken purely because the probe used 2000 tokens.** If a model produces no tool
calls, check `finish_reason` before concluding anything — `length` means starved, `stop` means it
genuinely declined.
`reasoning_effort` is the other lever, though supported values are provider-specific:
| Setting | Reasoning | Tool calls |
| ---------------------------------------------------- | --------- | ---------- |
| default | 6,583 ch | ❌ 0 |
| `"reasoning_effort": "none"` | 0 ch | ✅ 3 |
| `"reasoning_effort": "low"` | 5,088 ch | ✅ 3 |
| `"chat_template_kwargs": {"enable_thinking": false}` | 5,133 ch | ✅ 3 |
Only `"none"` actually eliminated reasoning on Scaleway; `"low"` had no measurable effect.
OpenPencil cannot send this today — see
[#454](https://github.com/open-pencil/open-pencil/issues/454).
### Known issue: reasoning models and the connection test
Some models (e.g. Kimi-K2.6) spend output tokens on `reasoning` before `content`. The connection
test uses `maxOutputTokens: 1`, so `content` comes back `null`. The test still passes because it
only checks that the request didn't throw — but don't tighten it to assert on returned text, or
reasoning models will fail it spuriously.
---
## Contributing results
Add a row, keep it dated, and say how you tested. Negative results are as useful as positive ones.
### 1. Test CORS
Run these checks from a shell. Curl is not subject to CORS, so this inspects the headers a browser
would enforce. A provider must pass both checks: the preflight must allow the origin, `POST` method,
and requested headers, and a successful POST must include `Access-Control-Allow-Origin`.
Read the key without placing it in shell history or the curl process arguments. The temporary curl
configuration is owner-readable only and is removed on exit.
```bash
BASE="https://api.example.com/v1"
MODEL="MODEL"
ORIGIN="https://openpencil.dev"
read -rsp "API key: " API_KEY; printf '\n'
CURL_CONFIG="$(mktemp)"
chmod 600 "$CURL_CONFIG"
trap 'rm -f "$CURL_CONFIG"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$API_KEY" > "$CURL_CONFIG"
unset API_KEY
# Browser preflight: verify the origin, POST method, and both requested headers.
curl -sD - -o /dev/null -X OPTIONS "$BASE/chat/completions" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" \
| grep -iE '^HTTP|^access-control-allow-(origin|methods|headers):'
# Successful response: use a valid key and model, then verify the origin header again.
curl --config "$CURL_CONFIG" -sD - -o /dev/null -X POST "$BASE/chat/completions" \
-H "Origin: $ORIGIN" \
-H "content-type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \
| grep -iE '^HTTP|^access-control-allow-origin:'
```
Also repeat the POST with an invalid key and record error-response CORS separately. It does not
prove whether successful requests work, but it determines whether users see useful authentication
errors or only an opaque network failure.
### 2. Test tool-call streaming
Ask the model to make several tool calls and save the raw SSE stream. Use at least 16384 output
tokens when supported; if the endpoint accepts less, record its highest supported budget.
```bash
STREAM_FILE="$(mktemp)"
trap 'rm -f "$CURL_CONFIG" "$STREAM_FILE"' EXIT
curl --config "$CURL_CONFIG" -sN -X POST "$BASE/chat/completions" \
-H "content-type: application/json" \
-d "{\"model\":\"$MODEL\",\"stream\":true,\"max_tokens\":16384,
\"messages\":[{\"role\":\"user\",\"content\":\"Draw a frog: green ellipse body, two white eye circles, a mouth line. Use create_node for each.\"}],
\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"create_node\",\"description\":\"Create a node on the canvas\",
\"parameters\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ELLIPSE\",\"RECTANGLE\",\"LINE\",\"TEXT\"]},
\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"},
\"fill\":{\"type\":\"string\"}},\"required\":[\"type\",\"x\",\"y\"]}}}]}" \
> "$STREAM_FILE"
```
Parse each SSE event as JSON, group fragments by tool-call index, require an ID on the first delta
for each index, and validate the concatenated arguments:
```bash
STREAM_FILE="$STREAM_FILE" node <<'JS'
const fs = require('node:fs')
const calls = new Map()
let malformedFirstDeltas = 0
for (const line of fs.readFileSync(process.env.STREAM_FILE, 'utf8').split(/\r?\n/)) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (!data || data === '[DONE]') continue
const event = JSON.parse(data)
for (const call of event.choices?.[0]?.delta?.tool_calls ?? []) {
const current = calls.get(call.index)
if (!current) {
if (typeof call.id !== 'string' || !call.id) malformedFirstDeltas++
calls.set(call.index, { id: call.id, arguments: call.function?.arguments ?? '' })
} else {
current.arguments += call.function?.arguments ?? ''
}
}
}
let invalidArguments = 0
for (const [index, call] of calls) {
try {
JSON.parse(call.arguments)
} catch {
invalidArguments++
console.error(`tool call ${index}: invalid arguments`)
}
}
console.log({ calls: calls.size, malformedFirstDeltas, invalidArguments })
if (malformedFirstDeltas || invalidArguments) process.exitCode = 1
JS
```
Use more than one prompt. Failures are often intermittent and can depend on where JSON lands on a
chunk boundary. Also record `finish_reason`; a `length` result requires a larger-budget retest
before it can be classified as a tool-calling failure.
### 3. Add your row
Include the compatibility test date, exact model ID, base URL pattern or regional hostname,
output-token budget, reasoning configuration, and a one-line verdict. If something is broken, say
what the failure looks like from the user's side — that's what makes the row actionable.
Include the provider's list price, its source URL, and a separate price-as-of date. A model that
streams flawlessly but costs 25× the alternative is not the right default for an agent loop, and a
table without price provenance goes stale silently.
**Record the `max_tokens` you used.** A reasoning model starved of output budget produces no tool
calls and looks broken. Two models on this page were wrongly marked "avoid" for exactly that
reason. Use at least 16384 when supported, and check `finish_reason` before recording a failure.
### A note on regional catalogs
Some providers serve different models per region behind different hostnames. Nebius Token Factory,
for example, carries Kimi-K3 on `eu-west2` but not on `us-central1`. Always record the exact base
URL you tested — "provider X doesn't have model Y" is only true for the endpoint you checked.
---
## Security note
In the browser, credentials default to encrypted IndexedDB persistence so users do not have to
re-enter them; Settings also offers session-only storage, which keeps secrets in memory until the
tab closes. The desktop build uses the OS credential store. Browser scripts running on the origin
can still use a credential in place, so prefer scoped keys with spend caps where the provider
offers them.

View file

@ -25,7 +25,7 @@ description: Встроенный ИИ-ассистент с более чем 9
| **Совместимый с OpenAI** | Любой эндпоинт с форматом API OpenAI | Собственный базовый URL + ключ. Поддержка переключения между API Completions и Responses. |
| **Совместимый с Anthropic** | Любой эндпоинт с форматом API Anthropic | Собственный базовый URL + ключ |
Без сервера, без подписки — ключ обращается напрямую к провайдеру.
Без сервера и подписки — ключ обращается напрямую к провайдеру. Запросы из браузера зависят от политики CORS провайдера, а надёжность потоковых вызовов инструментов различается между развёртываниями моделей. Измеренные результаты и воспроизводимые проверки приведены в англоязычном справочнике [совместимости BYOK-провайдеров и моделей](/programmable/byok-provider-compatibility).
## Возможности