Skip to content

fix(model-metadata): don't misdetect an OpenAI-compat proxy as LM Studio - #260

Merged
Kyzcreig merged 3 commits into
mainfrom
fix/lmstudio-misdetect-openai-proxy
Jul 10, 2026
Merged

Kyzcreig merged 3 commits into
mainfrom
fix/lmstudio-misdetect-openai-proxy

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

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/models as 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_metadata then 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 → resolved context_length to None → caller fell back to the 128K probe tier → 0.75 × 128K = 96K compaction 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/models entry 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 only id/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

  • regression: an OpenAI-compat proxy answering /api/v1/models with a 200 is not classified lm-studio;
  • shape-discriminator unit test (accepts genuine LM Studio, rejects OpenAI envelope, fails closed on malformed/empty);
  • updated the existing TTL-swap test's mock from an unrealistic {'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/bridge context_length advertisement fixes (Kyzcreig/claude-apx, claude-bpx) and the harness contract test (#259).

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).
@greptile-apps

greptile-apps Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes a misdetection bug where any HTTP 200 on /api/v1/models was treated as proof of an LM Studio server, causing loopback OpenAI-compatible proxies (claude-apx/claude-bpx) to be routed into the LM Studio metadata parser, which found none of its expected fields and fell back to a 128K context probe tier — triggering auto-compaction at ~96K on a 1M-context model.

  • Introduces _is_lmstudio_models_payload() to inspect the response body shape (requires LM Studio-native fields like loaded_instances, max_context_length, arch, publisher, or key; rejects the \"object\":\"list\" OpenAI envelope; requires both type+state together as a weaker signal rather than either alone; fails closed on parse errors).
  • Updates detect_local_server_type() to gate the \"lm-studio\" classification on the shape discriminator in addition to status 200, and updates existing test mocks to supply a realistic LM Studio-native payload (the prior {\"data\": []} mock no longer satisfies the stricter check).
  • Adds regression and unit tests: OpenAI-compat proxy not misdetected, shape discriminator accepts genuine LM Studio formats, rejects OpenAI envelope and ambiguous edge cases.

Confidence Score: 5/5

Safe to merge — the change narrows a detection criterion that was provably too broad, and all edge cases (empty payloads, parse failures, OpenAI envelope variants) fail closed without affecting non-LM Studio paths.

The shape discriminator correctly rejects the OpenAI envelope before any marker check, handles empty and malformed responses by failing closed, and the regression test directly exercises the real proxy payload that caused the original over-compaction. Mock updates in existing tests are accurate: the new LM Studio payloads carry multiple strong markers so the assertions are sound. The known trade-off (an LM Studio instance that has no model loaded and uses the data key will fall through the waterfall) is documented in the code and in prior review discussion.

No files require special attention.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (3): Last reviewed commit: "refine(model-metadata): address review —..." | Re-trigger Greptile

Comment thread agent/model_metadata.py Outdated
Comment thread agent/model_metadata.py Outdated
Comment thread agent/model_metadata.py Outdated
Kyzcreig added 2 commits July 10, 2026 08:28
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.
@Kyzcreig
Kyzcreig merged commit f79c528 into main Jul 10, 2026
35 checks passed
@Kyzcreig
Kyzcreig deleted the fix/lmstudio-misdetect-openai-proxy branch July 10, 2026 15:44
@Kyzcreig
Kyzcreig restored the fix/lmstudio-misdetect-openai-proxy branch September 21, 2026 10:32
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.

1 participant