Add OpenAI-compatible backends with OpenRouter preset #73

Manually merged
marvin8 merged 7 commits from feat/issue-72-openai-compatible-backends into main 2026-10-15 10:04:17 +00:00
Collaborator

One shared OpenAICompatibleBackend replaces the duplicated deepseek and moonshot backends and adds an openrouter preset, plus a generic WUMING_BACKEND=openai escape hatch for any OpenAI-compatible endpoint (Groq, Together, vLLM, LM Studio, …).

  • deepseek / moonshot / openrouter are preset rows (base URL, default model, key) over the shared class — existing configs keep working unchanged
  • OpenRouter preset: WUMING_OPENROUTER_KEY, default model deepseek/deepseek-v4.1-flash (verified against the live OpenRouter catalog), X-Title: wuming attribution header
  • Generic backend: WUMING_OPENAI_BASE_URL + WUMING_OPENAI_KEY + required WUMING_OPENAI_MODEL (fail-fast at startup when missing)
  • The 16384 max-token reasoning auto-raise now covers all OpenAI-compatible backends
  • Anthropic (Messages API) and Ollama (native /api/chat) keep their dedicated backends

Closes #72

One shared `OpenAICompatibleBackend` replaces the duplicated deepseek and moonshot backends and adds an `openrouter` preset, plus a generic `WUMING_BACKEND=openai` escape hatch for any OpenAI-compatible endpoint (Groq, Together, vLLM, LM Studio, …). - `deepseek` / `moonshot` / `openrouter` are preset rows (base URL, default model, key) over the shared class — existing configs keep working unchanged - OpenRouter preset: `WUMING_OPENROUTER_KEY`, default model `deepseek/deepseek-v4.1-flash` (verified against the live OpenRouter catalog), `X-Title: wuming` attribution header - Generic backend: `WUMING_OPENAI_BASE_URL` + `WUMING_OPENAI_KEY` + required `WUMING_OPENAI_MODEL` (fail-fast at startup when missing) - The 16384 max-token reasoning auto-raise now covers all OpenAI-compatible backends - Anthropic (Messages API) and Ollama (native `/api/chat`) keep their dedicated backends Closes #72
📝 document OpenAI-compatible backends and new env vars
Some checks failed
/ gitleaks (pull_request) Successful in 16s
/ checks (pull_request) Failing after 1m59s
/ deploy-docs (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 7m35s
cfc4a0eac8
forgejo-actions left a comment

WuMing

Found 6 issue(s). See inline comments below.

## WuMing Found **6** issue(s). See inline comments below.
@ -0,0 +78,4 @@
}
logger.debug(
"llm_request",
extra={"model": resolved_model, "system": system, "user": user},

security [MEDIUM]

A09: The full system and user prompts are written to debug logs. Prompts may contain PII, credentials, or proprietary source code, so they should not be logged even at debug level. Redact or omit prompt content from log extras.

**security** [MEDIUM] A09: The full system and user prompts are written to debug logs. Prompts may contain PII, credentials, or proprietary source code, so they should not be logged even at debug level. Redact or omit prompt content from log extras. <!-- wuming:sha256:1139b1826cbd6d56b58665673f125f3acf2467e2bc32125cacb61f1e9ea74f98 -->
marvin8 marked this conversation as resolved
@ -0,0 +81,4 @@
extra={"model": resolved_model, "system": system, "user": user},
)
headers = {"Authorization": f"Bearer {self._api_key}"}

code [LOW]

The backend unconditionally sends Authorization: Bearer {self._api_key}. When the key is empty this emits a malformed Bearer header, which can cause unauthenticated or local endpoints (e.g. LM Studio) to reject the request. Either validate a non-empty key at startup or omit the header when no key is configured.

**code** [LOW] The backend unconditionally sends `Authorization: Bearer {self._api_key}`. When the key is empty this emits a malformed `Bearer ` header, which can cause unauthenticated or local endpoints (e.g. LM Studio) to reject the request. Either validate a non-empty key at startup or omit the header when no key is configured. <!-- wuming:sha256:8d80d7b6ae54657a46e29a968a77d6f90687f8316d4ead658bae42bef902b55f -->
@ -0,0 +92,4 @@
headers=headers,
)
response.raise_for_status()
message = response.json()["choices"][0]["message"]

code [MEDIUM]

The response parser assumes the provider always returns a well-formed choices[0].message object. A malformed or empty response will raise KeyError/IndexError rather than a meaningful exception. Validate the shape before indexing, e.g. check that choices is non-empty and contains 'message', and raise a clear exception if the response is unexpected.

**code** [MEDIUM] The response parser assumes the provider always returns a well-formed `choices[0].message` object. A malformed or empty response will raise KeyError/IndexError rather than a meaningful exception. Validate the shape before indexing, e.g. check that `choices` is non-empty and contains 'message', and raise a clear exception if the response is unexpected. <!-- wuming:sha256:44d1703062b05009e314f122af711ebf5fd46e5f822962f92dbf7687fdd0754d -->
@ -0,0 +97,4 @@
return content
result = await with_retry(_attempt)
logger.debug("llm_response", extra={"model": resolved_model, "raw": result})

security [MEDIUM]

A09: The raw LLM response is written to debug logs. Responses can contain sensitive data or PII returned by the model and should not be logged verbatim. Redact or summarize the response before logging.

**security** [MEDIUM] A09: The raw LLM response is written to debug logs. Responses can contain sensitive data or PII returned by the model and should not be logged verbatim. Redact or summarize the response before logging. <!-- wuming:sha256:c3d5b45f2e1227ad202e9e70a6a8bb05121b5b274ce479897286488be2b56e7c -->
@ -243,0 +265,4 @@
app_title=_APP_TITLES.get(config.backend),
)
if config.backend == "openai":
if not config.openai_model:

code [HIGH]

The generic 'openai' backend only fail-fasts on a missing model. WUMING_OPENAI_BASE_URL is also required; if it is empty the backend will POST to '/chat/completions' and fail later with a confusing httpx error instead of at startup. Add a check like if not config.openai_base_url: raise ValueError('WUMING_OPENAI_BASE_URL is required when WUMING_BACKEND=openai').

**code** [HIGH] The generic 'openai' backend only fail-fasts on a missing model. WUMING_OPENAI_BASE_URL is also required; if it is empty the backend will POST to '/chat/completions' and fail later with a confusing httpx error instead of at startup. Add a check like `if not config.openai_base_url: raise ValueError('WUMING_OPENAI_BASE_URL is required when WUMING_BACKEND=openai')`. <!-- wuming:sha256:9bb4dcfaea5a5748c4c9724468edd11cec8b3ad609b4d3185fb02d7d45ccd188 -->
marvin8 marked this conversation as resolved
@ -704,0 +793,4 @@
expect(captured["headers"].get("x-title", "")).to_equal("")
@test("_build_backend raises ValueError when openai backend has no default model")

code [LOW]

There is a test for the missing-model failure, but no test that the generic 'openai' backend fails fast when WUMING_OPENAI_BASE_URL is missing. Add a test similar to test_build_backend_openai_requires_model that clears openai_base_url and asserts a ValueError is raised at startup.

**code** [LOW] There is a test for the missing-model failure, but no test that the generic 'openai' backend fails fast when WUMING_OPENAI_BASE_URL is missing. Add a test similar to `test_build_backend_openai_requires_model` that clears `openai_base_url` and asserts a ValueError is raised at startup. <!-- wuming:sha256:6059d99d9581eb453749596234c0b4c97499f3d23a3610c63957fb470b5557a9 -->
⬆️ bump httpx2 and toolchain, pin vulnerable transitives
All checks were successful
/ gitleaks (pull_request) Successful in 31s
/ pr-review (pull_request) Successful in 52s
/ checks (pull_request) Successful in 1m45s
/ deploy-docs (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
ffaed8b675

This PR is too large to review automatically (2181 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2181 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2181 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2181 lines; limit is 2000). Please consider splitting it into smaller PRs.
🐛 fail fast on missing OpenAI base URL and omit empty auth header
All checks were successful
/ gitleaks (pull_request) Successful in 32s
/ pr-review (pull_request) Successful in 54s
/ checks (pull_request) Successful in 1m46s
/ deploy-docs (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
d98e9d0db1
Author
Collaborator

src/wuming/main.py

The generic 'openai' backend only fail-fasts on a missing model. WUMING_OPENAI_BASE_URL is also required

✅ Fixed in commit d98e9d0 — _build_backend now raises ValueError("WUMING_OPENAI_BASE_URL is required when WUMING_BACKEND=openai") at startup when the base URL is empty, mirroring the model check.

[`src/wuming/main.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4906) > The generic 'openai' backend only fail-fasts on a missing model. WUMING_OPENAI_BASE_URL is also required ✅ Fixed in commit `d98e9d0` — `_build_backend` now raises `ValueError("WUMING_OPENAI_BASE_URL is required when WUMING_BACKEND=openai")` at startup when the base URL is empty, mirroring the model check.
Author
Collaborator

src/wuming/backends/openai_compatible.py

The backend unconditionally sends Authorization: Bearer {self._api_key}. When the key is empty this emits a malformed Bearer header

✅ Fixed in commit d98e9d0 — the Authorization header is now omitted entirely when the API key is empty, so keyless local endpoints (LM Studio, vLLM) work out of the box. Covered by a new test.

[`src/wuming/backends/openai_compatible.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4908) > The backend unconditionally sends `Authorization: Bearer {self._api_key}`. When the key is empty this emits a malformed `Bearer ` header ✅ Fixed in commit `d98e9d0` — the `Authorization` header is now omitted entirely when the API key is empty, so keyless local endpoints (LM Studio, vLLM) work out of the box. Covered by a new test.
Author
Collaborator

tests/test_main.py

no test that the generic 'openai' backend fails fast when WUMING_OPENAI_BASE_URL is missing

✅ Added in commit d98e9d0 — test_build_backend_openai_requires_base_url mirrors test_build_backend_openai_requires_model and asserts the startup ValueError.

[`tests/test_main.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4909) > no test that the generic 'openai' backend fails fast when WUMING_OPENAI_BASE_URL is missing ✅ Added in commit `d98e9d0` — `test_build_backend_openai_requires_base_url` mirrors `test_build_backend_openai_requires_model` and asserts the startup `ValueError`.

This PR is too large to review automatically (2218 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2218 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2218 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2218 lines; limit is 2000). Please consider splitting it into smaller PRs.
🐛 raise a clear error on malformed OpenAI-compatible responses
All checks were successful
/ gitleaks (pull_request) Successful in 14s
/ pr-review (pull_request) Successful in 53s
/ checks (pull_request) Successful in 1m56s
/ deploy-docs (pull_request) Has been skipped
/ publish-container (pull_request) Has been skipped
8d94103afa
Author
Collaborator

src/wuming/backends/openai_compatible.py

The response parser assumes the provider always returns a well-formed choices[0].message object

Fixed in commit 8d94103 (backend-only, option agreed with the maintainer): response parsing now happens after the retry loop and raises ValueError("malformed response from <base_url>: expected choices[0].message") when choices is missing, empty, or has no message. Transient HTTP errors are still retried; a malformed 200 body fails fast with an actionable message. Covered by two new tests. The dedicated Anthropic/Ollama backends keep their existing behaviour — out of scope here.

[`src/wuming/backends/openai_compatible.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4907) > The response parser assumes the provider always returns a well-formed `choices[0].message` object Fixed in commit `8d94103` (backend-only, option agreed with the maintainer): response parsing now happens after the retry loop and raises `ValueError("malformed response from <base_url>: expected choices[0].message")` when `choices` is missing, empty, or has no `message`. Transient HTTP errors are still retried; a malformed 200 body fails fast with an actionable message. Covered by two new tests. The dedicated Anthropic/Ollama backends keep their existing behaviour — out of scope here.
Author
Collaborator

src/wuming/backends/openai_compatible.py

A09: The full system and user prompts are written to debug logs. Prompts may contain PII, credentials, or proprietary source code

Declined — this is an intentional, documented debugging feature. Prompt/response dumping only happens at DEBUG level, which is off unless WUMING_VERBOSE=true is explicitly set. The README configuration table warns about exactly this danger: "Each backend logs the full LLM request payload (including diff text and file contents) and raw response before parsing. Treat these logs as sensitive — they may contain repository source code." The project agent docs carry the same warning as of commit 8d94103. Redacting payloads by default would defeat the purpose of WUMING_VERBOSE.

[`src/wuming/backends/openai_compatible.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4910) > A09: The full system and user prompts are written to debug logs. Prompts may contain PII, credentials, or proprietary source code Declined — this is an intentional, documented debugging feature. Prompt/response dumping only happens at DEBUG level, which is off unless `WUMING_VERBOSE=true` is explicitly set. The README configuration table warns about exactly this danger: "Each backend logs the full LLM request payload (including diff text and file contents) and raw response before parsing. Treat these logs as sensitive — they may contain repository source code." The project agent docs carry the same warning as of commit `8d94103`. Redacting payloads by default would defeat the purpose of `WUMING_VERBOSE`.

This PR is too large to review automatically (2269 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2269 lines; limit is 2000). Please consider splitting it into smaller PRs.
Author
Collaborator

src/wuming/backends/openai_compatible.py

A09: The raw LLM response is written to debug logs. Responses can contain sensitive data or PII returned by the model

Declined — same rationale as the prompt-logging finding: DEBUG-only, opt-in via WUMING_VERBOSE, and explicitly documented as sensitive in the README configuration table. Logging the raw response verbatim is the point of the verbose debugging feature.

[`src/wuming/backends/openai_compatible.py`](https://forge.marvin8.zone/marvin8/wuming/pulls/73#issuecomment-4911) > A09: The raw LLM response is written to debug logs. Responses can contain sensitive data or PII returned by the model Declined — same rationale as the prompt-logging finding: DEBUG-only, opt-in via `WUMING_VERBOSE`, and explicitly documented as sensitive in the README configuration table. Logging the raw response verbatim is the point of the verbose debugging feature.

This PR is too large to review automatically (2269 lines; limit is 2000). Please consider splitting it into smaller PRs.

This PR is too large to review automatically (2269 lines; limit is 2000). Please consider splitting it into smaller PRs.
marvin8 approved these changes 2026-10-15 10:02:44 +00:00
marvin8 manually merged commit e21c90975d into main 2026-10-15 10:04:17 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/wuming!73
No description provided.