Repository navigation
fix(model-metadata): don't misdetect an OpenAI-compat proxy as LM Studio - #260
Conversation
detect_local_server_type classified ANY 200 on /api/v1/models as LM Studio.
A loopback OpenAI-compatible server that also answers that path — e.g. the
fleet's Claude/Anthropic api-proxy (claude-apx) and bridge (claude-bpx), which
expose /api/v1/models for harness model-name validation — was therefore
misdetected as LM Studio. fetch_endpoint_model_metadata then took the LM Studio
branch, looked for LM Studio's native shape ({models:[{loaded_instances:[{config:
{context_length}}]}]}), found none of it in the plain OpenAI listing, and
resolved context_length to None → the caller fell back to a 128K probe tier.
Net effect: a 1M-context model (claude-fable-5) auto-compacted at ~96K
(0.75 * 128K) — the proxy correctly ADVERTISED context_length=1000000, but the
harness threw the payload away before reading it.
Fix: detection now inspects the /api/v1/models entry SHAPE via
_is_lmstudio_models_payload — it requires LM Studio-native markers
(loaded_instances / state / type / max_context_length / arch / publisher) and
rejects the OpenAI envelope (object=='list', or entries with only id/object/
owned_by). Fails closed on any parse error (caller continues its waterfall /
OpenAI-compat path). Verified live: the same proxy that returned None now
resolves claude-fable-5 -> 1000000, claude-haiku-4-5 -> 200000.
Tests: regression test that an OpenAI-compat proxy is not classified lm-studio;
shape-discriminator unit test (accept genuine LM Studio, reject OpenAI, fail
closed on malformed); updated the TTL-swap test's mock to a realistic LM Studio
payload (the old {'data': []} empty mock no longer satisfies the stricter check).
|
| Filename | Overview |
|---|---|
| agent/model_metadata.py | Adds _is_lmstudio_models_payload() shape discriminator and gates LM Studio detection on it; logic is sound, fails closed, addresses the misdetection root cause cleanly. |
| tests/agent/test_probe_cache_followups.py | Upgrades the TTL-swap mock to a genuine LM Studio payload, adds regression test for OpenAI-compat proxy misdetection, and adds comprehensive unit tests for the shape discriminator. |
| tests/agent/test_model_metadata_local_ctx.py | Extracts _lmstudio_resp() helper returning a realistic LM Studio-native payload and updates two existing tests to use it so they still correctly assert 'lm-studio' detection. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["detect_local_server_type(base_url)"] --> B["GET /api/v1/models"]
B --> C{status 200?}
C -- No --> D["Try /api/tags → Ollama?"]
C -- Yes --> E["_is_lmstudio_models_payload(r)"]
E --> F{object == 'list'?}
F -- Yes --> G["return False\n(OpenAI envelope → not LM Studio)"]
G --> D
F -- No --> H{entries list present\nand non-empty?}
H -- "empty + models key" --> I["return True\n(idle LM Studio)"]
H -- "empty + data key" --> J["return False\n(ambiguous, fail-closed)"]
J --> D
H -- non-empty --> K{any strong marker?\nloaded_instances / max_context_length\narch / publisher / key}
K -- Yes --> L["return True"]
K -- No --> M{type AND state\nboth present?}
M -- Yes --> L
M -- No --> J
L --> N["result = 'lm-studio'"]
D --> O["Try /v1/props → llamacpp?"]
O --> P["Try /version → vLLM?"]
P --> Q["Return result / None"]
N --> Q
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["detect_local_server_type(base_url)"] --> B["GET /api/v1/models"]
B --> C{status 200?}
C -- No --> D["Try /api/tags → Ollama?"]
C -- Yes --> E["_is_lmstudio_models_payload(r)"]
E --> F{object == 'list'?}
F -- Yes --> G["return False\n(OpenAI envelope → not LM Studio)"]
G --> D
F -- No --> H{entries list present\nand non-empty?}
H -- "empty + models key" --> I["return True\n(idle LM Studio)"]
H -- "empty + data key" --> J["return False\n(ambiguous, fail-closed)"]
J --> D
H -- non-empty --> K{any strong marker?\nloaded_instances / max_context_length\narch / publisher / key}
K -- Yes --> L["return True"]
K -- No --> M{type AND state\nboth present?}
M -- Yes --> L
M -- No --> J
L --> N["result = 'lm-studio'"]
D --> O["Try /v1/props → llamacpp?"]
O --> P["Try /version → vLLM?"]
P --> Q["Return result / None"]
N --> Q
Reviews (3): Last reviewed commit: "refine(model-metadata): address review —..." | Re-trigger Greptile
The stricter detect_local_server_type (shape check, not bare 200) correctly rejected two TestDetectLocalServerTypeAuth mocks that returned a 200 with no LM Studio marker fields. Those tests assert auth-header passing and URL non-doubling, not payload shape, so give them a genuine LM Studio native body (id/type/state/max_context_length/loaded_instances) via a shared _lmstudio_resp helper. Detection still classifies lm-studio; the behavior under test is unchanged.
…eneric-field FP
Folds three Greptile P2 review points on the LM Studio shape discriminator:
- docstring↔code drift: 'key' (publisher-qualified id) is now an actual strong
marker in the tuple, not just mentioned in prose;
- idle LM Studio (started, zero models loaded → empty list) is re-accepted, but
ONLY on the LM Studio-native 'models' key; an ambiguous empty '{"data":[]}'
still fails closed (an idle OpenAI server is indistinguishable there);
- 'state'/'type' are individually too generic (common in arbitrary JSON) and no
longer count alone — they now require EACH OTHER (type AND state) as a weaker
signal, so a bespoke proxy with a lone 'type'/'state' entry isn't misclassified.
The object=='list' negative guard still rejects every standard OpenAI-compat
server up front. Live re-verified: the Claude proxy still resolves fable→1M.
Tests extended for key-only accept, lone type/state reject, idle-models accept,
empty-data reject.
The bug (the REAL root cause of early auto-compaction)
A live
claude-fable-5(1M-context) session auto-compacted at ~110K — far below the window. Traced live:detect_local_server_type()classified any HTTP 200 on/api/v1/modelsas LM Studio. The fleet's loopback Claude api-proxy (claude-apx) and bridge (claude-bpx) answer that path (for harness model-name validation), so they were misdetected as LM Studio.fetch_endpoint_model_metadatathen took the LM Studio branch, looked for LM Studio's native shape ({models:[{loaded_instances:[{config:{context_length}}]}]}), found none in the plain OpenAI listing → resolvedcontext_lengthtoNone→ caller fell back to the 128K probe tier →0.75 × 128K = 96Kcompaction on a 1M model.Critically: the proxies correctly advertise
context_length: 1000000— the harness threw the payload away before reading it because it went down the wrong parser.The fix
Detection now inspects the
/api/v1/modelsentry shape (_is_lmstudio_models_payload): requires an LM Studio-native marker (loaded_instances/state/type/max_context_length/arch/publisher) and rejects the OpenAI envelope (object=='list', or entries with onlyid/object/owned_by). Fails closed on any parse error — the caller just continues its detection waterfall / OpenAI-compat path.Verified live against the real proxy that was returning
None:claude-fable-5 → 1000000,claude-haiku-4-5 → 200000.Tests
/api/v1/modelswith a 200 is not classifiedlm-studio;{'data': []}to a genuine LM Studio payload (the empty mock no longer satisfies the stricter, correct check).139 passed across
test_probe_cache_followups.py+test_model_metadata.py. Companion to the proxy/bridgecontext_lengthadvertisement fixes (Kyzcreig/claude-apx, claude-bpx) and the harness contract test (#259).