Add initial llm-bridge project
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# ChatGPT provider configuration, driven entirely by this file.
|
||||
# Selectors change when the web UI is updated: edit them here (reloaded live),
|
||||
# never in the Rust source. Validate with: llm-gateway selftest chatgpt
|
||||
|
||||
[provider]
|
||||
name = "chatgpt"
|
||||
display_name = "OpenAI ChatGPT"
|
||||
web_url = "https://chatgpt.com/"
|
||||
new_conversation_url = "https://chatgpt.com/"
|
||||
# Captures the web conversation id from the current URL (one capture group).
|
||||
conversation_url_pattern = "^https://chatgpt\\.com/c/([0-9a-zA-Z-]{8,})"
|
||||
default_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1"]
|
||||
enabled = true
|
||||
|
||||
[selectors]
|
||||
input_field = "div#prompt-textarea, div.ProseMirror[contenteditable='true']"
|
||||
# The id and the data-testid are language independent; the aria-label fallbacks
|
||||
# only match an English UI (see providers/selectors.md, "Localised user
|
||||
# interfaces").
|
||||
send_button = "button[data-testid='send-button'], button#composer-submit-button, button[aria-label='Send prompt'], button[aria-label='Envoyer la requête']"
|
||||
response_container = "div[data-message-author-role='assistant']"
|
||||
streaming_indicator = "button[data-testid='stop-button']"
|
||||
file_input = "input[type='file']"
|
||||
new_chat_button = "a[data-testid='create-new-chat-button']"
|
||||
# Clicked once the answer has settled, to read the provider's own Markdown
|
||||
# through the clipboard. Looked up inside the answer node, so it must be
|
||||
# relative to it. Leave empty to always convert the DOM instead.
|
||||
copy_button = "button[data-testid='copy-turn-action-button'], button[aria-label='Copy'], button[aria-label='Copier']"
|
||||
login_page_indicator = "button[data-testid='login'], form[action*='login'], a[href*='auth/login'], a[href*='auth/0']"
|
||||
captcha_indicator = "iframe[src*='captcha'], iframe[src*='challenges.cloudflare.com'], div#challenge-form"
|
||||
error_banner = "[data-testid='error-message'], div[role='alert']"
|
||||
|
||||
[login]
|
||||
url_patterns = ["/auth/login", "/login", "accounts.google.com", "/auth/0"]
|
||||
# The page a signed-out visitor lands on (title "ChatGPT: Chat, Work, Create &
|
||||
# Code with AI") has no prompt field and shows these call to action buttons.
|
||||
# The text patterns are only used when that field is missing, so a signed-in
|
||||
# conversation is never mistaken for a sign-in screen.
|
||||
text_patterns = ["log in", "sign up", "create an account"]
|
||||
|
||||
[rate_limit]
|
||||
text_patterns = [
|
||||
"too many requests",
|
||||
"rate limit",
|
||||
"trop de requêtes",
|
||||
"réessayez plus tard",
|
||||
"you've reached",
|
||||
]
|
||||
|
||||
[timeouts]
|
||||
page_load = 30
|
||||
response_generation = 120
|
||||
response_timeout = 180
|
||||
|
||||
[input]
|
||||
poll_interval_ms = 400
|
||||
quiet_ms = 1500
|
||||
inject_method = "auto"
|
||||
|
||||
[options]
|
||||
supports_images = true
|
||||
supports_streaming = true
|
||||
supports_new_chat = true
|
||||
|
||||
# Multi-account. A ChatGPT account is a browser profile, so declaring two
|
||||
# accounts gives two independent sessions, each with its own tab pool, driven
|
||||
# side by side. Address them with the model string:
|
||||
#
|
||||
# {"model": "chatgpt"} -> the default account
|
||||
# {"model": "chatgpt@perso"} -> by account id
|
||||
# {"model": "[email protected]@gmail.com"} -> by address
|
||||
# {"model": "chatgpt@perso/gpt-4o"} -> account and model
|
||||
#
|
||||
# Sign each one in once: llm-gateway login chatgpt --account perso
|
||||
# Their profiles live in profiles/chatgpt/accounts/<account>/user-data-dir.
|
||||
#
|
||||
# [[accounts]]
|
||||
# id = "perso"
|
||||
# email = "[email protected]"
|
||||
# label = "Personal"
|
||||
# default = true
|
||||
#
|
||||
# [[accounts]]
|
||||
# id = "pro"
|
||||
# email = "[email protected]"
|
||||
# label = "Work"
|
||||
@@ -0,0 +1,79 @@
|
||||
# Claude (claude.ai) provider configuration, driven entirely by this file.
|
||||
# Selectors change when the web UI is updated: edit them here (reloaded live),
|
||||
# never in the Rust source. Validate with: llm-gateway selftest claude
|
||||
#
|
||||
# The values below come from the public claude.ai DOM observed in 2026 and from
|
||||
# published selector lists (May 2026). They are a starting point, not a
|
||||
# guarantee: run 'llm-gateway selftest claude' before relying on them.
|
||||
|
||||
[provider]
|
||||
name = "claude"
|
||||
display_name = "Anthropic Claude"
|
||||
web_url = "https://claude.ai/new"
|
||||
new_conversation_url = "https://claude.ai/new"
|
||||
# Captures the web conversation id from the current URL (one capture group).
|
||||
conversation_url_pattern = '^https://claude\.ai/chat/([0-9a-zA-Z-]{8,})'
|
||||
default_models = ["claude-sonnet-4", "claude-opus-4", "claude-haiku-4"]
|
||||
enabled = true
|
||||
|
||||
[selectors]
|
||||
# Claude uses ProseMirror, so the editor is a contenteditable div rather than a
|
||||
# textarea. The fallbacks cover the composer variants seen behind A/B tests.
|
||||
input_field = "div.ProseMirror[contenteditable='true'], div[contenteditable='true'][data-placeholder], [data-testid='composer-input']"
|
||||
send_button = "button[aria-label='Send Message'], button[aria-label='Send message'], button[data-testid='send-button'], button[type='submit']"
|
||||
# One node per assistant answer, in document order.
|
||||
response_container = "div.font-claude-response, div[data-is-streaming], div.font-claude-message"
|
||||
streaming_indicator = "button[aria-label='Stop response'], button[aria-label='Stop Response']"
|
||||
file_input = "input[type='file']"
|
||||
new_chat_button = "a[href='/new'], button[aria-label='New chat']"
|
||||
login_page_indicator = "a[href*='/login'], button[data-testid='login-button'], form[action*='login']"
|
||||
captcha_indicator = "iframe[src*='challenges.cloudflare.com'], iframe[title*='challenge'], div#challenge-form"
|
||||
error_banner = "[role='alert'], div[data-testid='error']"
|
||||
# Relative to the answer node: the hover toolbar button that copies the answer
|
||||
# as Markdown.
|
||||
copy_button = "button[aria-label='Copy'], button[data-testid='copy-button']"
|
||||
|
||||
[login]
|
||||
url_patterns = ["/login", "/magic-link", "accounts.google.com"]
|
||||
# Only consulted when the prompt field is missing, so a chat page that happens
|
||||
# to mention "log in" is never misclassified.
|
||||
text_patterns = ["sign in", "log in", "continue with google"]
|
||||
|
||||
[rate_limit]
|
||||
text_patterns = [
|
||||
"message limit",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"you've reached",
|
||||
"limite de messages",
|
||||
"trop de requêtes",
|
||||
"réessayez plus tard",
|
||||
]
|
||||
|
||||
[timeouts]
|
||||
page_load = 30
|
||||
response_generation = 120
|
||||
response_timeout = 180
|
||||
|
||||
[input]
|
||||
poll_interval_ms = 400
|
||||
quiet_ms = 1500
|
||||
inject_method = "auto"
|
||||
|
||||
[options]
|
||||
supports_images = true
|
||||
supports_streaming = true
|
||||
supports_new_chat = true
|
||||
|
||||
# Multi-account: each account is a separate browser profile, so several
|
||||
# subscriptions can be driven side by side. Declare them like this and address
|
||||
# them with model = "claude@work".
|
||||
#
|
||||
# [[accounts]]
|
||||
# id = "perso"
|
||||
# email = "[email protected]"
|
||||
# default = true
|
||||
#
|
||||
# [[accounts]]
|
||||
# id = "work"
|
||||
# email = "[email protected]"
|
||||
@@ -0,0 +1,73 @@
|
||||
# DeepSeek (chat.deepseek.com) provider configuration, driven entirely by this
|
||||
# file. Selectors change when the web UI is updated: edit them here (reloaded
|
||||
# live), never in the Rust source. Validate with: llm-gateway selftest deepseek
|
||||
#
|
||||
# The values below combine the public chat.deepseek.com DOM with published
|
||||
# selector lists (May 2026). They are a starting point, not a guarantee: run
|
||||
# 'llm-gateway selftest deepseek' before relying on them.
|
||||
|
||||
[provider]
|
||||
name = "deepseek"
|
||||
display_name = "DeepSeek"
|
||||
web_url = "https://chat.deepseek.com/"
|
||||
new_conversation_url = "https://chat.deepseek.com/"
|
||||
# Captures the web conversation id from the current URL (one capture group).
|
||||
conversation_url_pattern = '^https://chat\.deepseek\.com/a/chat/s/([0-9a-zA-Z-]{8,})'
|
||||
default_models = ["deepseek-chat", "deepseek-reasoner"]
|
||||
enabled = true
|
||||
|
||||
[selectors]
|
||||
# The composer is a plain <textarea>. DeepSeek localises its placeholder and the
|
||||
# id seen in older builds is gone, so the wording observed on the live page comes
|
||||
# first and the older guesses are kept as fallbacks.
|
||||
input_field = "textarea#chat-input, textarea[placeholder*='Message DeepSeek'], textarea[placeholder*='Envoyer'], textarea[placeholder*='Send a message'], textarea[placeholder*='message']"
|
||||
send_button = "div[role='button'][aria-label='Send'], button[aria-label='Send message'], button[data-testid='send-button']"
|
||||
# The answer body is rendered by the "ds-markdown" component; the attribute
|
||||
# first variant mirrors the ChatGPT markup.
|
||||
response_container = "div[data-message-author-role='assistant'], div.ds-markdown, div[class*='markdown-body']"
|
||||
streaming_indicator = "div[role='button'][aria-label*='Stop'], button[aria-label*='Stop'], div[class*='_stop']"
|
||||
file_input = "input[type='file']"
|
||||
new_chat_button = "a[href='/'], div[class*='new-chat'], button[aria-label='New chat']"
|
||||
login_page_indicator = "div[class*='ds-modal'], a[href*='sign_in'], button[data-testid='login-button']"
|
||||
captcha_indicator = "iframe[src*='captcha'], div[class*='captcha'], div#challenge-form"
|
||||
error_banner = "[role='alert'], div[class*='ds-toast'], div[class*='error']"
|
||||
# Relative to the answer node: the toolbar button that copies the answer.
|
||||
copy_button = "[aria-label='Copy'], [data-testid='copy-button'], div[class*='ds-icon-button'][role='button']"
|
||||
|
||||
[login]
|
||||
url_patterns = ["/sign_in", "/login", "account.deepseek.com"]
|
||||
text_patterns = ["log in", "sign in", "log in with google", "se connecter"]
|
||||
|
||||
[rate_limit]
|
||||
text_patterns = [
|
||||
"too many requests",
|
||||
"rate limit",
|
||||
"please try again later",
|
||||
"请求过于频繁",
|
||||
"服务器繁忙",
|
||||
"trop de requêtes",
|
||||
]
|
||||
|
||||
[timeouts]
|
||||
page_load = 30
|
||||
response_generation = 120
|
||||
# DeepSeek R1 reasons for a long time before answering: keep a generous budget.
|
||||
response_timeout = 300
|
||||
|
||||
[input]
|
||||
poll_interval_ms = 400
|
||||
quiet_ms = 1500
|
||||
inject_method = "auto"
|
||||
|
||||
[options]
|
||||
supports_images = true
|
||||
supports_streaming = true
|
||||
supports_new_chat = true
|
||||
|
||||
# Multi-account: each account is a separate browser profile. Address them with
|
||||
# model = "deepseek@perso".
|
||||
#
|
||||
# [[accounts]]
|
||||
# id = "perso"
|
||||
# email = "[email protected]"
|
||||
# default = true
|
||||
@@ -0,0 +1,169 @@
|
||||
# Provider selectors
|
||||
|
||||
Every selector lives in a provider configuration file
|
||||
(`~/.llm-gateway/providers/<provider>.toml`), never in the Rust source. When a
|
||||
web UI changes, edit the TOML: the server picks the change up within a second
|
||||
(no restart, no browser relaunch). `llm-gateway selftest <provider>` tells you
|
||||
whether the current selectors still work.
|
||||
|
||||
## Why this file exists
|
||||
|
||||
Web UIs change without notice. The three failure modes we care about are:
|
||||
|
||||
| Symptom | Reported as | What to do |
|
||||
|---|---|---|
|
||||
| The prompt field disappeared | HTTP 500, code `selector_missing` | Update `selectors.input_field` |
|
||||
| The send button disappeared | HTTP 500, code `selector_missing` | Update `selectors.send_button` |
|
||||
| The answer container moved | empty answer or HTTP 500 | Update `selectors.response_container` |
|
||||
| The streaming indicator is wrong | answers truncated or 504 | Update `selectors.streaming_indicator` |
|
||||
| The session expired | HTTP 401, code `requires_login` | Run `llm-gateway login <provider>` |
|
||||
|
||||
A failing capture always leaves a screenshot and a DOM dump in
|
||||
`~/.llm-gateway/debug/`, plus one line per failure in `debug/index.jsonl`.
|
||||
|
||||
## How to update a selector
|
||||
|
||||
1. Open the provider page in a normal browser, signed in.
|
||||
2. Open the developer tools, inspect the element you need.
|
||||
3. Prefer stable hooks in this order: `data-testid` > `id` > semantic attributes
|
||||
(`aria-label`, `role`, `type`) > a CSS class. Avoid generated class names such
|
||||
as `.css-1x2y3z`: they change on every deploy.
|
||||
4. Put a comma separated list in the TOML when a UI has several variants
|
||||
(`div#prompt-textarea, div.ProseMirror[contenteditable='true']`): the first
|
||||
match wins and the fallbacks keep you running through a partial rollout.
|
||||
5. Run `llm-gateway selftest <provider>` and keep the TOML only when it reports
|
||||
`ok`.
|
||||
|
||||
## Required and optional selectors
|
||||
|
||||
| Key | Required | Used for |
|
||||
|---|---|---|
|
||||
| `input_field` | yes | focusing the editor, verifying the injected text, detecting an empty editor after submit |
|
||||
| `send_button` | yes | submitting the prompt (Enter is the fallback) |
|
||||
| `response_container` | yes | one node per assistant answer, in document order; the driver reads the n-th node. It must select the **answer text**, not the whole exchange: a container that also holds the action toolbar would capture the word "Copy" as part of the answer |
|
||||
| `copy_button` | no | the per-answer "copy" control, which gives the answer as Markdown through the clipboard. It may be inside the answer node or a sibling of it: the driver looks for it there and never elsewhere on the page, so the copy button of a previous answer is never clicked |
|
||||
| `streaming_indicator` | recommended | element that only exists while the answer is generated (a stop button) |
|
||||
| `file_input` | for images | hidden input fed through `DOM.setFileInputFiles` |
|
||||
| `attachment_indicator` | optional | chip shown once an upload has been ingested |
|
||||
| `new_chat_button` | optional | starting a brand new conversation without reloading |
|
||||
| `login_page_indicator` | recommended | detecting a sign-in page even when the URL does not say so. On chatgpt.com the signed-out landing page exposes `button[data-testid='login']` |
|
||||
| `[login] text_patterns` | recommended | lowercase substrings of the visible text that reveal a signed-out page; only consulted when the prompt field is missing, so a chat page that mentions "log in" is never misclassified |
|
||||
| `captcha_indicator` | recommended | detecting a bot challenge |
|
||||
| `error_banner` | recommended | surfacing the upstream error message to the API client |
|
||||
| `model_selector` | reserved | not driven yet: the gateway never changes the model in the UI |
|
||||
|
||||
## Answer fidelity (Markdown)
|
||||
|
||||
A web UI renders its answer as rich HTML and `innerText` flattens it: a table
|
||||
collapses into loose lines and a code block loses its fences. Once the answer has
|
||||
settled the driver therefore reads it a second time, in this order:
|
||||
|
||||
1. **the clipboard** — it clicks the provider's own copy button and reads the
|
||||
clipboard over CDP (`Browser.setPermission` then
|
||||
`navigator.clipboard.readText()`). The provider copies exactly the Markdown it
|
||||
rendered, so nothing is guessed. Requires `selectors.copy_button`.
|
||||
2. **the DOM** — it converts the answer node's `innerHTML` to Markdown
|
||||
(headings, lists, quotes, links, images, fenced code, GitHub tables).
|
||||
3. **the visible text** — the historical behaviour, used when both fail.
|
||||
|
||||
`[capture] markdown` chooses between them and a provider may override it in its
|
||||
own `[input]` section. Streamed deltas always come from the DOM, so they stay
|
||||
plain text; the final answer — the one a non-streaming client receives — is the
|
||||
converted one, and the source used is logged as `fidelity`.
|
||||
|
||||
## Current status
|
||||
|
||||
| Provider | File | Last validated | Status |
|
||||
|---|---|---|---|
|
||||
| OpenAI ChatGPT | `providers/chatgpt.toml` | 2026-09-18, signed-in session | `selftest` ok; `fidelity = "clipboard"` |
|
||||
| Anthropic Claude | `providers/claude.toml` | 2026-09-18, signed-in session | `selftest` ok; `fidelity = "clipboard"` |
|
||||
| DeepSeek | `providers/deepseek.toml` | 2026-09-18, signed-in session | `selftest` ok; **`fidelity = "dom"`**: the copy button has not been observed yet |
|
||||
| Local fixture (tests only) | generated by `tests/e2e_browser.rs` | 2026-09-18 | validated: full pipeline, streaming, login, captcha, rate limit, error banner, timeout, missing selector, rewrite, attachment, Markdown fidelity |
|
||||
|
||||
The Claude and DeepSeek selectors were collected from public sources rather than
|
||||
observed on a signed-in session. Treat them as a starting point: the first run of
|
||||
`llm-gateway selftest <provider>` tells you whether they still match, and the
|
||||
failure modes above tell you which key to fix.
|
||||
|
||||
The ChatGPT selectors shipped in this repository are the ones observed on the
|
||||
public web UI in September 2026. They are a starting point, not a guarantee:
|
||||
run the selftest before relying on them.
|
||||
|
||||
## Upgrading the installed files
|
||||
|
||||
The provider files under `~/.llm-gateway/providers/` are **copies** made on the
|
||||
first run: editing the ones in this repository does not change them. After
|
||||
upgrading the binary, refresh them with:
|
||||
|
||||
```bash
|
||||
llm-gateway config init --force # keeps the previous file as <provider>.toml.bak
|
||||
```
|
||||
|
||||
## Localised user interfaces
|
||||
|
||||
A provider translates its own UI, and the browser locale of the machine does not
|
||||
change that: the same ChatGPT account serves a French page under `lang="fr-CA"`
|
||||
and an English one under `lang="en-US"`. Every `aria-label` fallback therefore
|
||||
only matches one language, which is why the stable hooks come first:
|
||||
|
||||
| Observed (French UI, September 2026) | Selector to prefer |
|
||||
|---|---|
|
||||
| `<button type="submit" id="composer-submit-button" aria-label="Envoyer la requête" data-testid="send-button">` | `button[data-testid='send-button']`, `button#composer-submit-button` |
|
||||
| the answer toolbar copy control | `button[data-testid='copy-turn-action-button']` |
|
||||
| `<textarea placeholder="Message DeepSeek" name="search" rows="2">` | `textarea[placeholder*='Message DeepSeek']` - the English placeholder and the `#chat-input` id of older builds are gone |
|
||||
|
||||
The `[login] text_patterns` follow the same rule: they are lowercase substrings of
|
||||
the page text, so a French sign-in page needs French patterns
|
||||
(`"se connecter"`, `"s'inscrire"`) next to the English ones. A page that is not
|
||||
recognised as a sign-in page is reported as a missing prompt field instead, which
|
||||
is a confusing way to learn that the session expired.
|
||||
|
||||
## Several accounts of one provider
|
||||
|
||||
A signed-in account is a browser profile, so driving two ChatGPT accounts means
|
||||
two profiles, two sessions and two tab pools. Declare them in the provider file:
|
||||
|
||||
```toml
|
||||
[[accounts]]
|
||||
id = "perso" # the short name used in the model string
|
||||
email = "[email protected]" # also accepted as the account key
|
||||
label = "Personal" # free-form, shown by 'llm-gateway list'
|
||||
default = true # used by {"model": "chatgpt"}
|
||||
|
||||
[[accounts]]
|
||||
id = "pro"
|
||||
email = "[email protected]"
|
||||
```
|
||||
|
||||
Then sign each one in once and address it in the model string:
|
||||
|
||||
```bash
|
||||
llm-gateway login chatgpt --account perso
|
||||
llm-gateway login chatgpt@pro # the same thing
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{"model": "chatgpt"} // the default account
|
||||
{"model": "chatgpt@pro"} // by id
|
||||
{"model": "[email protected]@gmail.com"} // by address
|
||||
{"model": "chatgpt@pro/gpt-4o"} // account and model
|
||||
```
|
||||
|
||||
Their profiles live in `profiles/<provider>/accounts/<account>/user-data-dir`.
|
||||
A provider that declares no account keeps the historical
|
||||
`profiles/<provider>/user-data-dir`, so adding accounts later means signing in
|
||||
again for the first one. Each account has its own tab pool, so `max_tabs`
|
||||
applies per account.
|
||||
|
||||
## Adding a new provider
|
||||
|
||||
1. Copy `providers/chatgpt.toml` to `~/.llm-gateway/providers/<name>.toml`.
|
||||
2. Set `provider.name`, `provider.web_url`, `provider.new_conversation_url` and
|
||||
`provider.conversation_url_pattern` (one capture group, the web conversation
|
||||
id extracted from the page URL).
|
||||
3. Fill in the selectors, then `llm-gateway selftest <name>`.
|
||||
4. `llm-gateway login <name>` once, and the provider is usable as a model:
|
||||
`{"model": "<name>"}` or `{"model": "<name>/<model>"}`.
|
||||
|
||||
Nothing else is provider specific: the capture loop, the error mapping, the
|
||||
conversation store and the API are shared.
|
||||
Reference in New Issue
Block a user