Merge remote-tracking branch 'origin/farm/d3ff0cb8/detect-lm-studio-vision'
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user