Skip to content

Connect Ask AI to any OpenAI-compatible endpoint - #84

Merged
mberman84 merged 7 commits into
Forward-Future:mainfrom
Ganesh75-web:custom-endpoint
Nov 11, 2026
Merged

mberman84 merged 7 commits into
Forward-Future:mainfrom
Ganesh75-web:custom-endpoint

Conversation

@Ganesh75-web

@Ganesh75-web Ganesh75-web commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Ask AI works only with ChatGPT, Claude Code and OpenRouter. Many people use a provider Folio doesn't list (Z.ai, DeepSeek, Mistral, Groq, a company gateway) or a model on their own computer (Ollama, LM Studio). All of them accept OpenAI-style Chat Completions, which Folio's OpenRouter client already sends, but the OpenRouter address was fixed.

Change

A Custom endpoint provider in Settings → Ask AI, with a base URL, an optional API key and a model ID. It reuses the OpenRouter client's Chat Completions code:

  • Shared code: electron/chat-completions.cjs holds the stream reader, the error messages, the secure-storage check and the write ordering. Both openrouter.cjs and the new custom-endpoint.cjs use it. OpenRouter's behavior and its existing tests are unchanged. ModelPicker is shared by both settings screens.
  • Connecting: Folio checks GET {base}/models before it saves anything.
    • The key is stored only with system credential protection, the same as the OpenRouter key.
    • Servers without a key get no Authorization header.
    • The model list comes from /models when the server has one; otherwise you type a model ID.
  • Addresses (the first requirement from Ask AI: connect any OpenAI-compatible endpoint (base URL + key + model) #78):
    • The base URL must be https://. Plain http:// is accepted only when every address the name resolves to is on this computer or the local network. Its requests also run behind a local-only guard, so if the name later resolves to a public address (a company name off the VPN, for example), nothing is sent.
    • Any address that resolves there, http or https, goes through the existing "Open a local address?" confirmation (confirmLocalAddress in main.cjs, now passed to ai.register) before Folio sends anything. Declining sends nothing.
    • The approved origin and addresses are saved with the endpoint. Each request runs in its own in-memory session behind network.cjs's guardSession, the guard URL downloads use. So requests reach only the approved local addresses, and Folio connects to addresses it resolved itself.
    • If a name later resolves to an unapproved local address, the request is refused, and the message asks you to connect it again.
  • Not allowed in the address: credentials, query strings and fragments. Redirects are refused.
  • Ask AI panel: shows the model and "Sent to {host}". Passage questions send the passage instead of the whole document, the same as OpenRouter.
  • README: setup and privacy sections updated.

Issue or approval

Approved in #78: #78 (comment) (Matthew Berman, "Approved as you scoped it"), with two requirements:

  1. https except for local addresses, which go through the existing local-network confirmation. Done as described above.
  2. CI on Windows, macOS and Linux. I tested on Windows only; please check macOS and Linux.

How I checked it

All on Windows 11 with Node 26.5. I didn't test on macOS or Linux.

  • npx tsc --noEmit and npm run build pass.
  • node --test scripts/test-custom-endpoint.cjs scripts/test-openrouter.cjs scripts/test-network-security.cjs scripts/test-claude-path.cjs: 27 passed, 0 failed (1 Unix-only test skipped). There are 8 new tests for the endpoint and 2 for the guard. They use a stand-in for the system's encryption. They cover:
    • http refused unless every address is local (a name with one local and one public address is refused), and refused later if the name moves to a public address;
    • the question asked before anything is sent, and declining sends nothing;
    • the approval saved and passed on every request, and a hand-edited http config without an approval isn't trusted;
    • the key encrypted, a rejected key keeping the working setup, and a failed save keeping the old key with the old address;
    • keyless servers;
    • optional model lists;
    • streaming, citations, passage questions, errors that name the server, and Stop;
    • a clear message when an answer runs past 5 minutes, and a 404 that points at the base URL (such as a missing /v1).
  • Smoke check (scripts/smoke-custom-endpoint.mjs, added to smoke-test.mjs): runs the real app against a real HTTP server on folio-local.test (the existing host-resolver rule). It covers the question being asked and declined with nothing sent, then connecting, models, a streamed answer, a citation jumping to page 2, Stop closing the connection at the server, choosing a typed model ID, and removal. This passes locally.
    • The full npm run smoke stops later at the highlight step (smoke-test.mjs, .highlightEditor). It stops at the same step on unmodified main on this machine, so I think it's a local environment difference (Node 26 here, Node 22 in CI), not this change.
  • Live, not committed: I drove the built app with no network mocks.
    • A Z.ai GLM Coding Plan (https://api.z.ai/api/coding/paas/v4, model glm-5.3-flash) answered "The ferry leaves at dawn (p. 2)" in about 2 seconds, and the citation jumped to page 2. No local-address question was asked.
    • A keyless server on http://127.0.0.1 triggered the question once, received no Authorization header, and streamed its answer.

Screenshots of the settings screen and an answer are attached below.

Code written by Claude Opus 5.5 in Claude Code. I reviewed and tested it.

🤖 Generated with Claude Code

Ganesh75-web and others added 2 commits November 7, 2026 20:59
Adds a Custom endpoint provider: a base URL, an optional API key, and a model ID. It works with
any server that accepts OpenAI-style Chat Completions, such as another provider's API, a company
gateway, or a local model in Ollama or LM Studio.

- The address must use https, except for this computer or the local network. Credentials,
  query strings and fragments in the address are refused.
- Connecting checks GET /models before anything is saved. A key is stored only with system
  credential protection, as the OpenRouter key is. Keyless servers send no Authorization header.
- The model list is filled from /models when the server has one; otherwise a typed model ID works.
- The OpenRouter stream reader and error messages move to chat-completions.cjs and are shared.
  OpenRouter behaves as before. ModelPicker is shared by both providers.

Refs Forward-Future#78

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he approved one

Per the maintainer's requirement on Forward-Future#78: a local address goes through the existing local-network
confirmation before Folio sends anything.

- Connecting resolves the address first. http is allowed only when it resolves to this computer or
  the local network, and any local address (http or https) shows the same "Open a local address?"
  question Folio asks for links. Declining sends nothing.
- The approved origin and addresses are saved with the endpoint. Every request runs in its own
  in-memory session behind network.cjs's guard, the one URL downloads use, so it can reach only those
  local addresses; a name that later resolves somewhere local unapproved is refused with a message
  to reconnect.
- The smoke check now uses a real server on folio-local.test and the real question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ganesh75-web

Copy link
Copy Markdown
Contributor Author

Screenshots from the Windows run (they're hosted in a gist, since the GitHub CLI can't attach images):

Settings → Ask AI → Custom endpoint, connected to a Z.ai endpoint with a model chosen:

Custom endpoint settings

Ask AI panel, with an answer from that endpoint and its page citation. The footer shows the model and "Sent to api.z.ai":

Answer with citation

Ganesh75-web and others added 2 commits November 7, 2026 21:43
…wrong base URLs

A plain http endpoint was only checked to be local when it was connected. If its name later
resolved to a public address (a company name off the VPN, for example), requests went there
unencrypted. Its requests now run behind a local-only guard and are refused instead.

An answer that runs past the 5-minute limit now says so instead of showing the raw abort
error, and a 404 without an API error points at the base URL (such as a missing /v1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th its address when a save fails

A name that resolves to both a local and a public address was treated as
local, so plain http could reach the public one. Plain http now needs every
address to be local, both when saving and in the request guard. Any local
address still means Folio asks first.

The key and the address are saved as two files. If the address can't be
written, the previous key is put back (or removed), so the saved address is
never sent another server's key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ganesh75-web

Copy link
Copy Markdown
Contributor Author

Before screenshot, to go with the ones above (CONTRIBUTING asks for before and after). Same screen and window size, from unmodified main (0590fac) on Windows:

Before: Settings → Ask AI. The provider list has only ChatGPT, Claude and OpenRouter, and OpenRouter's address is fixed:

Settings before the change

After: the "Custom endpoint" screenshot in the comment above.

@mberman84
mberman84 merged commit 3d2fbb1 into Forward-Future:main Nov 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants