Skip to content

Commit aac81b8

Browse files
committed
fix mermaid validation
1 parent 7f55227 commit aac81b8

7 files changed

Lines changed: 1081 additions & 415 deletions

File tree

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,23 @@ codewiki --version
4545
CodeWiki supports multiple LLM providers: **OpenAI-compatible**, **Anthropic**, **AWS Bedrock**, **Azure OpenAI**, plus subscription mode via **Claude Code** and **Codex** CLIs (no API key required).
4646

4747
```bash
48-
# Anthropic
48+
# OpenAI-compatible
4949
codewiki config set \
5050
--provider openai-compatible \
51-
--api-key 3rRvIM7UNTmwt9ugiFi0Zkjgn0JA8WOjEUMfATsO \
52-
--base-url https://gateway.ai.cloudflare.com/v1/def31e2cf1530789c604bdaa2abbfcf1/openai-proxy/compat \
53-
--main-model openai/gpt-5.4 \
54-
--cluster-model openai/gpt-5.4 \
55-
--fallback-model openai/gpt-5.3
51+
--api-key YOUR_API_KEY \
52+
--base-url https://api.anthropic.com \
53+
--main-model claude-sonnet-4 \
54+
--cluster-model claude-sonnet-4 \
55+
--fallback-model glm-4p5
56+
57+
# Anthropic
58+
codewiki config set \
59+
--provider anthropic \
60+
--api-key YOUR_API_KEY \
61+
--base-url https://api.anthropic.com \
62+
--main-model claude-sonnet-4 \
63+
--cluster-model claude-sonnet-4 \
64+
--fallback-model glm-4p5
5665

5766
# Azure OpenAI
5867
codewiki config set \

codewiki/src/be/llm_services.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic_ai.providers.openai import OpenAIProvider
1414
from pydantic_ai.models.openai import OpenAIModelSettings
1515
from pydantic_ai.models.fallback import FallbackModel
16-
from openai import OpenAI
16+
from openai import OpenAI, BadRequestError
1717

1818
from codewiki.src.config import Config
1919

@@ -24,12 +24,12 @@ def _should_use_max_completion_tokens(model_name: str, base_url: str) -> bool:
2424
"""
2525
Determine whether to use max_completion_tokens instead of max_tokens.
2626
27-
Newer OpenAI models (o1, o3, gpt-4o, etc.) require max_completion_tokens.
28-
Anthropic and other providers still use max_tokens.
27+
Newer OpenAI models (o1, o3, o4, gpt-4o, gpt-5, etc.) require
28+
max_completion_tokens. Anthropic and other providers still use max_tokens.
2929
"""
3030
model_lower = model_name.lower()
3131
# OpenAI models that require max_completion_tokens
32-
new_openai_patterns = ("o1", "o3", "gpt-4o", "gpt-4-turbo")
32+
new_openai_patterns = ("o1", "o3", "o4", "gpt-4o", "gpt-4-turbo", "gpt-5")
3333
if any(pattern in model_lower for pattern in new_openai_patterns):
3434
return True
3535
# If base_url points to OpenAI directly, newer models may need it
@@ -180,23 +180,51 @@ def call_llm(
180180
# Default: OpenAI-compatible
181181
client = create_openai_client(config)
182182

183-
# Use the correct token parameter based on model/provider
184-
token_kwargs = {}
185-
if _should_use_max_completion_tokens(model, config.llm_base_url):
186-
token_kwargs["max_completion_tokens"] = config.max_tokens
187-
logger.debug("Using max_completion_tokens=%d for model %s", config.max_tokens, model)
188-
else:
189-
token_kwargs["max_tokens"] = config.max_tokens
190-
191-
response = client.chat.completions.create(
192-
model=model,
193-
messages=[{"role": "user", "content": prompt}],
194-
temperature=temperature,
195-
**token_kwargs
196-
)
183+
# Use the correct token parameter based on model/provider; if the server
184+
# rejects our choice, swap to the other token kwarg and retry once.
185+
use_completion_tokens = _should_use_max_completion_tokens(model, config.llm_base_url)
186+
primary_key = "max_completion_tokens" if use_completion_tokens else "max_tokens"
187+
fallback_key = "max_tokens" if use_completion_tokens else "max_completion_tokens"
188+
189+
base_kwargs = {
190+
"model": model,
191+
"messages": [{"role": "user", "content": prompt}],
192+
"temperature": temperature,
193+
}
194+
195+
try:
196+
response = client.chat.completions.create(
197+
**base_kwargs,
198+
**{primary_key: config.max_tokens},
199+
)
200+
except BadRequestError as e:
201+
if _is_unsupported_token_param_error(e, primary_key):
202+
logger.info(
203+
"Provider rejected %s for model %s; retrying with %s.",
204+
primary_key, model, fallback_key,
205+
)
206+
response = client.chat.completions.create(
207+
**base_kwargs,
208+
**{fallback_key: config.max_tokens},
209+
)
210+
else:
211+
raise
197212
return response.choices[0].message.content
198213

199214

215+
def _is_unsupported_token_param_error(err: BadRequestError, param: str) -> bool:
216+
"""Return True if *err* is the OpenAI "unsupported_parameter" error for *param*."""
217+
body = getattr(err, "body", None) or {}
218+
if isinstance(body, dict):
219+
error = body.get("error") or {}
220+
if isinstance(error, dict):
221+
if error.get("param") == param and error.get("code") == "unsupported_parameter":
222+
return True
223+
# Fallback: message-based sniff for proxies that don't preserve structure
224+
msg = str(err).lower()
225+
return "unsupported parameter" in msg and param in msg
226+
227+
200228
def _call_llm_via_litellm(
201229
prompt: str,
202230
config: Config,

codewiki/src/be/utils.py

Lines changed: 86 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -143,92 +143,106 @@ def extract_mermaid_blocks(content: str) -> List[Tuple[int, str]]:
143143
return mermaid_blocks
144144

145145

146+
_PYTHONMONKEY_BROKEN = False
147+
148+
149+
async def _try_pythonmonkey_parse(diagram_content: str) -> str | None:
150+
"""Attempt to parse via PythonMonkey/mermaid-parser-py.
151+
152+
Returns the extracted parse-error message, "" on success, or None when
153+
PythonMonkey itself is unusable (broken JS event loop binding on
154+
Python 3.13+) so the caller can fall back to mermaid-py.
155+
"""
156+
global _PYTHONMONKEY_BROKEN
157+
if _PYTHONMONKEY_BROKEN:
158+
return None
159+
160+
import sys
161+
import os
162+
163+
try:
164+
from mermaid_parser.parser import parse_mermaid_py
165+
except Exception:
166+
_PYTHONMONKEY_BROKEN = True
167+
return None
168+
169+
old_stderr = sys.stderr
170+
sys.stderr = open(os.devnull, 'w')
171+
try:
172+
if (
173+
_main_loop is not None
174+
and _main_loop.is_running()
175+
and threading.get_ident() != _main_loop_thread_ident
176+
):
177+
fut = asyncio.run_coroutine_threadsafe(
178+
parse_mermaid_py(diagram_content), _main_loop
179+
)
180+
await asyncio.wrap_future(fut)
181+
else:
182+
await parse_mermaid_py(diagram_content)
183+
return ""
184+
except Exception as e:
185+
error_str = str(e)
186+
# PythonMonkey 1.3.1 only supports Python 3.8-3.11; on newer Pythons
187+
# every JS call raises this. Latch the failure once so subsequent
188+
# diagrams skip the broken path and go straight to mermaid-py.
189+
if "cannot find a running Python event-loop" in error_str:
190+
_PYTHONMONKEY_BROKEN = True
191+
return None
192+
match = re.search(r"Error:(.*?)(?=Stack Trace:|$)", error_str, re.DOTALL)
193+
if match:
194+
return match.group(0).strip()
195+
# Unknown error from the JS parser — fall back rather than surface it.
196+
return None
197+
finally:
198+
sys.stderr.close()
199+
sys.stderr = old_stderr
200+
201+
202+
def _parse_via_mermaid_py(diagram_content: str) -> str:
203+
"""Validate via mermaid-py. Returns parse-error text, or "" if valid.
204+
205+
mermaid-py raises MermaidError on parse failure and returns an SVG body
206+
on success — we must drive the result off the exception, not the body
207+
text, otherwise a successful SVG gets reported as a parse error.
208+
"""
209+
import mermaid as md
210+
try:
211+
md.Mermaid(diagram_content)
212+
return ""
213+
except Exception as e:
214+
return str(e)
215+
216+
146217
async def validate_single_diagram(diagram_content: str, diagram_num: int, line_start: int) -> str:
147218
"""
148219
Validate a single mermaid diagram.
149-
220+
150221
Args:
151222
diagram_content: The mermaid diagram content
152223
diagram_num: Diagram number for error reporting
153224
line_start: Starting line number in the file
154-
225+
155226
Returns:
156227
Error message if invalid, empty string if valid
157228
"""
158-
import sys
159-
import os
160-
from io import StringIO
161-
162-
core_error = ""
163-
164-
try:
165-
from mermaid_parser.parser import parse_mermaid_py
166-
# logger.debug("Using mermaid-parser-py to validate mermaid diagrams")
167-
168-
try:
169-
# Redirect stderr to suppress mermaid parser JavaScript errors
170-
old_stderr = sys.stderr
171-
sys.stderr = open(os.devnull, 'w')
172-
173-
try:
174-
if (
175-
_main_loop is not None
176-
and _main_loop.is_running()
177-
and threading.get_ident() != _main_loop_thread_ident
178-
):
179-
# Caller is on a worker-thread loop (caw FastMCP path).
180-
# Run the coroutine on the loop where PythonMonkey was
181-
# bound so its asyncio.get_running_loop() succeeds.
182-
fut = asyncio.run_coroutine_threadsafe(
183-
parse_mermaid_py(diagram_content), _main_loop
184-
)
185-
json_output = await asyncio.wrap_future(fut)
186-
else:
187-
json_output = await parse_mermaid_py(diagram_content)
188-
finally:
189-
# Restore stderr
190-
sys.stderr.close()
191-
sys.stderr = old_stderr
192-
except Exception as e:
193-
error_str = str(e)
194-
195-
# Extract the core error information from the exception message
196-
# Look for the pattern that contains "Parse error on line X:"
197-
error_pattern = r"Error:(.*?)(?=Stack Trace:|$)"
198-
match = re.search(error_pattern, error_str, re.DOTALL)
199-
200-
if match:
201-
core_error = match.group(0).strip()
202-
core_error = core_error
203-
else:
204-
logger.error(f"No match found for error pattern, fallback to mermaid-py\n{error_str}")
205-
logger.error(f"Traceback: {traceback.format_exc()}")
206-
raise Exception(error_str)
207-
208-
except Exception as e:
209-
logger.warning("Using mermaid-py to validate mermaid diagrams")
229+
core_error = await _try_pythonmonkey_parse(diagram_content)
230+
if core_error is None:
210231
try:
211-
import mermaid as md
212-
# Create Mermaid object and check response
213-
render = md.Mermaid(diagram_content)
214-
core_error = render.svg_response.text
215-
232+
core_error = _parse_via_mermaid_py(diagram_content)
216233
except Exception as e:
217234
return f" Diagram {diagram_num}: Exception during validation - {str(e)}"
218235

219-
# Check if response indicates a parse error
220-
if core_error:
221-
# Extract line number from parse error and calculate actual line in markdown file
222-
line_match = re.search(r'line (\d+)', core_error)
223-
if line_match:
224-
error_line_in_diagram = int(line_match.group(1))
225-
actual_line_in_file = line_start + error_line_in_diagram
226-
newline = '\n'
227-
return f"Diagram {diagram_num}: Parse error on line {actual_line_in_file}:{newline}{newline.join(core_error.split(newline)[1:])}"
228-
else:
229-
return f"Diagram {diagram_num}: {core_error}"
230-
231-
return "" # No error
236+
if not core_error:
237+
return ""
238+
239+
line_match = re.search(r'line (\d+)', core_error)
240+
if line_match:
241+
error_line_in_diagram = int(line_match.group(1))
242+
actual_line_in_file = line_start + error_line_in_diagram
243+
newline = '\n'
244+
return f"Diagram {diagram_num}: Parse error on line {actual_line_in_file}:{newline}{newline.join(core_error.split(newline)[1:])}"
245+
return f"Diagram {diagram_num}: {core_error}"
232246

233247

234248
if __name__ == "__main__":

0 commit comments

Comments
 (0)