Merge remote-tracking branch 'origin/farm/d3ff0cb8/detect-lm-studio-vision'

This commit is contained in:
can1357
2026-06-18 16:58:42 +02:00
6 changed files with 265 additions and 11 deletions
+1
View File
@@ -65,6 +65,7 @@
- Fixed `/plan <prompt>` and `/goal <objective>` to preserve the typed slash-command line in TUI input history when entering those modes from off ([#2887](https://github.com/can1357/oh-my-pi/issues/2887)).
- Fixed `/model` in the TUI to open the active-session model switcher instead of the role-assignment picker ([#2846](https://github.com/can1357/oh-my-pi/issues/2846)).
- Fixed Perplexity web search collapsing every upstream failure to a generic `401 No authentication method available` once all auth methods failed: the fallback loop now rethrows the last classified provider error (`402`/credits-exhausted, `429`, `5xx`), so quota and rate-limit failures are no longer mis-reported as authorization errors. The generic 401 is now only a defensive fallback for the no-method-ran case.
- Fixed LM Studio runtime discovery to use native `/api/v0/models` metadata so `inspect_image` can select VLM models. ([#2945](https://github.com/can1357/oh-my-pi/issues/2945))
### Security
@@ -13,6 +13,7 @@ import {
resolveModelReference,
stripBracketedModelIdAffixes,
} from "@oh-my-pi/pi-catalog/identity";
import { fetchLmStudioNativeModelMetadata } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
import { isRecord } from "@oh-my-pi/pi-utils";
import type { ProviderDiscovery } from "./models-config-schema";
@@ -379,18 +380,25 @@ export async function discoverOpenAIModelsList(
const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
let headers = baseHeaders;
const attempt = async (h: Record<string, string>) => {
const res = await ctx.fetch(modelsUrl, {
headers: h,
signal: AbortSignal.timeout(10_000),
});
const nativeMetadataPromise =
providerConfig.discovery.type === "lm-studio"
? fetchLmStudioNativeModelMetadata(baseUrl, ctx.fetch, { headers: h })
: Promise.resolve(null);
const [res, nativeMetadata] = await Promise.all([
ctx.fetch(modelsUrl, {
headers: h,
signal: AbortSignal.timeout(10_000),
}),
nativeMetadataPromise,
]);
if (!res.ok) {
throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
}
headers = h;
return res;
return [res, nativeMetadata] as const;
};
const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
const response = apiKey
const [response, nativeMetadata] = apiKey
? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
: await attempt(baseHeaders);
const payload = (await response.json()) as {
@@ -401,8 +409,12 @@ export async function discoverOpenAIModelsList(
for (const item of models) {
const id = item.id;
if (!id) continue;
const nativeMetadataForModel = nativeMetadata?.get(id);
const contextWindow =
toPositiveNumberOrUndefined(item.max_model_len) ?? toPositiveNumberOrUndefined(item.context_length) ?? 128000;
toPositiveNumberOrUndefined(item.max_model_len) ??
toPositiveNumberOrUndefined(item.context_length) ??
nativeMetadataForModel?.contextWindow ??
128000;
discovered.push(
buildModel({
id,
@@ -411,7 +423,7 @@ export async function discoverOpenAIModelsList(
provider: providerConfig.provider,
baseUrl,
reasoning: false,
input: ["text"],
input: nativeMetadataForModel?.input ?? ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: Math.min(contextWindow, discoveryDefaultMaxTokens(providerConfig.api)),
@@ -60,6 +60,53 @@ describe("ModelRegistry LM Studio Fixes", () => {
expect(available.some(m => m.provider === "lm-studio")).toBe(true);
});
test("marks LM Studio native VLM models as image-capable", async () => {
const fetchMock: FetchImpl = input => {
const url = String(input);
if (url === "http://127.0.0.1:1234/api/v0/models") {
return Promise.resolve(
new Response(
JSON.stringify({
data: [
{
id: "qwen/qwen3.6-27b",
type: "vlm",
capabilities: ["tool_use"],
max_context_length: 262144,
},
{ id: "plain-llm", type: "llm" },
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
if (url === "http://127.0.0.1:1234/v1/models") {
return Promise.resolve(
new Response(
JSON.stringify({
data: [
{ id: "qwen/qwen3.6-27b", object: "model" },
{ id: "plain-llm", object: "model" },
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
return Promise.resolve(new Response(null, { status: 404 }));
};
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
await registry.refresh();
const vision = registry.find("lm-studio", "qwen/qwen3.6-27b");
const text = registry.find("lm-studio", "plain-llm");
expect(vision?.input).toEqual(["text", "image"]);
expect(vision?.contextWindow).toBe(262144);
expect(text?.input).toEqual(["text"]);
});
test("LM_STUDIO_BASE_URL can target any local OpenAI-compatible /v1 server", async () => {
const originalBaseUrl = Bun.env.LM_STUDIO_BASE_URL;
Bun.env.LM_STUDIO_BASE_URL = "http://127.0.0.1:11434/v1";